Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
6945227
Test approval notification settling
austinywang Sep 1, 2026
c7bf20c
Settle Codex approval notifications
austinywang Sep 1, 2026
4305096
Fix approval coordinator scheduler default
austinywang Sep 1, 2026
57b1211
Address approval notification review findings
austinywang Sep 1, 2026
c487d70
Harden approval notification correlation
austinywang Sep 1, 2026
ee0f5b6
Exercise mismatched Codex turn context
austinywang Sep 1, 2026
9d66574
Fix notification delivery argument order
austinywang Sep 1, 2026
32a4e27
Import Foundation in feed event tests
austinywang Sep 1, 2026
326b77e
Harden Codex approval notification settling
austinywang Sep 1, 2026
bd6dc3c
Avoid Codex hook cleanup scans on launch
austinywang Sep 1, 2026
7e6749f
Harden approval notification lifecycle bounds
austinywang Sep 1, 2026
8e696af
Bound approval coordinator pane state
austinywang Sep 1, 2026
110ace5
Open hook transcripts atomically
austinywang Sep 1, 2026
504edba
Fence dismissed approvals and document clear selectors
austinywang Sep 1, 2026
5b50497
Keep unresolved approvals visible
austinywang Sep 1, 2026
6de6e7a
Preserve non-Codex approval attention routing
austinywang Sep 1, 2026
4c7d084
Preserve legacy and ambiguous approval routing
austinywang Sep 1, 2026
f442f4c
Preserve approval identity strength through delivery
austinywang Sep 1, 2026
61a0ddd
Fence derived approval collisions
austinywang Sep 1, 2026
1c28ce1
Carry derived approval ambiguity metadata
austinywang Sep 1, 2026
3fb779e
Fix notification enqueue argument ordering
austinywang Sep 1, 2026
730b9f1
Refresh coalesced approval notifications
austinywang Sep 1, 2026
8980543
Keep newer approval stages after correlation clears
austinywang Sep 1, 2026
9878322
Restore read-only Codex wrapper hook inventory
austinywang Sep 1, 2026
2eaf805
Fix rebased notification clear usage declaration
austinywang Sep 1, 2026
b12142b
Address Codex approval review findings
austinywang Sep 1, 2026
9e81852
Harden correlated approval delivery and queue generations
austinywang Sep 1, 2026
12f7f98
Fix notification delivery argument ordering
austinywang Sep 1, 2026
c6d58f4
Fix notification queue Swift initialization
austinywang Sep 1, 2026
d01c944
Preserve producer correlation in approval delivery
austinywang Sep 1, 2026
b2d492c
Use toolchain-compatible approval alias filtering
austinywang Sep 1, 2026
222ce25
Fix approval delivery initializer ordering
austinywang Sep 1, 2026
c7e0650
Repair hosted unit-test target fixtures
austinywang Sep 1, 2026
c77da74
Fix notification metadata pending parser initialization
austinywang Sep 2, 2026
5b90cde
Reject malformed approval-shaped metadata
austinywang Sep 2, 2026
4e3e6aa
Stabilize Codex hook hosted fixtures
austinywang Sep 2, 2026
64d7fab
Stabilize Codex hook hosted fixtures
austinywang Sep 2, 2026
533a0be
Fix Codex hook supervisor and fixture races
austinywang Sep 2, 2026
7c3b7e2
Make Codex hook watchdog cleanup deterministic
austinywang Sep 2, 2026
86f596b
Allow bounded hosted watchdog reaping
austinywang Sep 2, 2026
a769d23
Capture hosted watchdog process state
austinywang Sep 2, 2026
cef0971
Kill Codex watchdog timer through owner
austinywang Sep 2, 2026
2254c3f
Fix generated Codex hook shell quoting
austinywang Sep 2, 2026
f87287c
Reap Codex watchdog timer descendants
austinywang Sep 2, 2026
5e3c710
Force-reap Codex watchdog descendants
austinywang Sep 2, 2026
2231753
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
01fc9af
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
0781c37
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
7d9368d
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
4614907
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
4d9282c
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
a6565e5
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
946a68b
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 6, 2026
fbf01a8
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 7, 2026
e0015ac
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 8, 2026
45400fb
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 8, 2026
b64ead1
Merge branch 'main' into issue-10017-codex-autoapprove-notify-swarm
austinywang Sep 8, 2026
6ea30e1
Merge remote-tracking branch 'origin/main' into issue-10017-codex-aut…
austinywang Sep 8, 2026
1064c8f
Test approval queue races and semantic notification settling
austinywang Sep 8, 2026
681ae2b
Fix approval lifecycle races and settle journal-owned Codex notificat…
austinywang Sep 8, 2026
6ceb818
Preserve settling for Codex hooks without a shared native ID
austinywang Sep 8, 2026
2fdaaaf
Capture approval mutation generations atomically with queue admission
austinywang Sep 8, 2026
a4bf746
Merge remote-tracking branch 'origin/main' into issue-10017-codex-aut…
austinywang Sep 8, 2026
d39d4f9
Test asymmetric Codex IDs and unrelated notification boundaries
austinywang Sep 8, 2026
1e7971d
Keep Codex request correlation stable across transports and unrelated…
austinywang Sep 8, 2026
83b9f36
Merge remote-tracking branch 'origin/main' into issue-10017-codex-aut…
austinywang Sep 8, 2026
425b1dd
Run Codex hook wire regressions against the built CLI in CI
austinywang Sep 8, 2026
7413a1c
Align Codex CLI regressions with semantic notification commands
austinywang Sep 8, 2026
408a610
Provide a live pane target for hook and Feed protocol parity
austinywang Sep 8, 2026
e0c03c7
Model the completed prior turn in the stale Codex Stop fixture
austinywang Sep 8, 2026
054e7bc
Avoid SIGPIPE false negatives in hosted unit-suite execution guards
austinywang Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-depot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:
suite_output=$(run_unit_suite "-only-testing:cmuxTests/$suite") || suite_status=$?
printf '%s\n' "$suite_output"
if [ "$suite_status" -ne 0 ]; then return "$suite_status"; fi
if ! printf '%s\n' "$suite_output" | grep -Eq 'Test run with [1-9][0-9]* tests|Executed [1-9][0-9]* tests'; then
if ! grep -Eq 'Test run with [1-9][0-9]* tests|Executed [1-9][0-9]* tests' <<< "$suite_output"; then
echo "No tests executed for $suite" >&2
return 1
fi
Expand Down
451 changes: 443 additions & 8 deletions CLI/AgentHookNotificationPolicy.swift

Large diffs are not rendered by default.

56 changes: 25 additions & 31 deletions CLI/CMUXCLI+AgentHookPayload.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Darwin
import Foundation

extension CMUXCLI {
Expand Down Expand Up @@ -352,46 +353,39 @@ extension CMUXCLI {
maxBytes: UInt64
) -> [String]? {
let expandedPath = NSString(string: path).expandingTildeInPath
guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: expandedPath)) else {
// Open and validate one descriptor. A path-only stat followed by a
// separate FileHandle open is a TOCTOU window in which a FIFO/device
// could replace the transcript and block the synchronous hook.
// `O_NOFOLLOW` rejects a final symlink and `O_NONBLOCK` keeps even a
// raced special-file open from waiting before fstat can reject it.
let descriptor = Darwin.open(
expandedPath,
O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK
)
guard descriptor >= 0 else { return nil }
defer { _ = Darwin.close(descriptor) }
var metadata = stat()
guard Darwin.fstat(descriptor, &metadata) == 0,
(metadata.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else {
return nil
}
defer { try? handle.close() }

func isASCIIWhitespace(_ byte: UInt8) -> Bool {
byte == 0x09 || byte == 0x0A || byte == 0x0D || byte == 0x20
}

func hasCompleteLineAfterLeadingBoundary(_ data: Data, readStart: UInt64) -> Bool {
guard readStart > 0 else { return true }
guard let newline = data.firstIndex(of: 0x0A) else { return false }
return data[data.index(after: newline)...].contains { !isASCIIWhitespace($0) }
}
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)

let size: UInt64
do {
size = try handle.seekToEnd()
var readStart = size > maxBytes ? size - maxBytes : 0
let readStart = size > maxBytes ? size - maxBytes : 0
try handle.seek(toOffset: readStart)
guard var data = try handle.readToEnd(), !data.isEmpty else {
// Read exactly the bounded window. `readToEnd()` can observe bytes
// appended after `seekToEnd()` and silently exceed the rollout
// budget on a busy Codex transcript.
let initialReadLength = Int(min(maxBytes, UInt64(Int.max)))
guard var data = try handle.read(upToCount: initialReadLength), !data.isEmpty else {
return nil
}
let maxWindowBytes = maxBytes > UInt64.max / 8 ? UInt64.max : maxBytes * 8

while !hasCompleteLineAfterLeadingBoundary(data, readStart: readStart), readStart > 0 {
let currentWindowBytes = size - readStart
guard currentWindowBytes < maxWindowBytes else { break }
let remainingWindowBytes = maxWindowBytes - currentWindowBytes
let expansionBytes = min(readStart, maxBytes, remainingWindowBytes)
guard expansionBytes > 0 else { break }

readStart -= expansionBytes
try handle.seek(toOffset: readStart)
guard let expandedData = try handle.readToEnd(), !expandedData.isEmpty else {
return nil
}
data = expandedData
}

// Keep the hook read strictly bounded. If the window begins in the
// middle of a very long line, dropping that partial line is safer
// than expanding into an unbounded transcript prefix.
if readStart > 0, let newline = data.firstIndex(of: 0x0A) {
data.removeSubrange(data.startIndex...newline)
}
Expand Down
40 changes: 31 additions & 9 deletions CLI/CMUXCLI+CodexFireAndForgetHooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,11 @@ extension CMUXCLI {
}

/// Removes obsolete regular files only when their names prove cmux ownership.
/// Live Codex sessions may still hold paths from another tagged build, and
/// concurrent launches can briefly overlap script generation, so collection
/// waits until no Codex process is running and leaves recent files alone.
/// This runs only during an explicit hook install, never on wrapper launch
/// or automatic reconciliation. Since an older immutable script may still
/// be referenced by a long-lived Codex process, fail closed whenever any
/// Codex process is running; the process probe is intentionally outside the
/// launch path.
static func garbageCollectCodexHookScripts(retaining filenames: Set<String>) {
guard !hasRunningCodexProcess(),
let directory = codexHookScriptsDirectory(),
Expand All @@ -285,8 +287,9 @@ extension CMUXCLI {
return
}

let newestRemovableDate = Date().addingTimeInterval(-60)
for url in contents where !filenames.contains(url.lastPathComponent) {
let newestRemovableDate = Date().addingTimeInterval(-24 * 60 * 60)
let removableCandidates = contents.compactMap { url -> (url: URL, date: Date)? in
guard !filenames.contains(url.lastPathComponent) else { return nil }
let values = try? url.resourceValues(forKeys: [
.contentModificationDateKey,
.isRegularFileKey,
Expand All @@ -295,13 +298,28 @@ extension CMUXCLI {
values?.isRegularFile == true,
let modificationDate = values?.contentModificationDate,
modificationDate < newestRemovableDate else {
continue
return nil
}
try? FileManager.default.removeItem(at: url)
return (url, modificationDate)
}
// Keep cleanup bounded if a damaged or very old installation has
// accumulated an unexpectedly large number of generated files, while
// deleting the oldest batch so later explicit installs make progress.
let boundedCandidates = removableCandidates
.sorted { lhs, rhs in
if lhs.date != rhs.date { return lhs.date < rhs.date }
return lhs.url.lastPathComponent < rhs.url.lastPathComponent
}
.prefix(256)
for url in boundedCandidates {
try? FileManager.default.removeItem(at: url.url)
}
}

/// Conservatively detects sessions that may still reference an older hook generation.
/// A running Codex process may have loaded an immutable hook path that is
/// absent from the current config. Cleanup is explicit-install-only, so a
/// synchronous fail-closed probe protects that process without adding
/// launch latency or a background polling task.
private static func hasRunningCodexProcess() -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
Expand All @@ -319,7 +337,11 @@ extension CMUXCLI {

static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( timer=; trap \"kill \\$timer 2>/dev/null || true; wait \\$timer 2>/dev/null || true; exit 0\" HUP INT TERM; sleep 30 & timer=\"$!\"; wait \"$timer\" 2>/dev/null || true; timer=; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; wait \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
// Keep timer ownership in one process and stop it through a file
// handshake. A signal-based supervisor can receive TERM before its
// trap/timer assignment is installed, orphaning the timer for a fast
// child; the explicit parent-owned timer is always waited/reaped.
let runner = "payload=\"$1\"; shift; timer_stop=\"$payload.timer-stop\"; timer_done=\"$payload.timer-done\"; kill_timer_tree() { timer_root=\"$1\"; for timer_child in $(/usr/bin/pgrep -P \"$timer_root\" 2>/dev/null || true); do kill_timer_tree \"$timer_child\"; done; kill -KILL \"$timer_root\" 2>/dev/null || true; }; rm -f \"$timer_stop\" \"$timer_done\"; ( sleep 30 & timer=\"$!\"; while [ ! -e \"$timer_stop\" ]; do timer_state=$(/bin/ps -o state= -p \"$timer\" 2>/dev/null | /usr/bin/tr -d \"[:space:]\"); case \"$timer_state\" in \"\"|Z*) wait \"$timer\" 2>/dev/null || true; printf done >\"$timer_done\"; exit 0;; esac; /bin/sleep 0.05; done; kill_timer_tree \"$timer\"; wait \"$timer\" 2>/dev/null || true; exit 0 ) & timer_supervisor=\"$!\"; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( while [ ! -s \"$timer_done\" ] && [ ! -e \"$timer_stop\" ]; do /bin/sleep 0.05; done; if [ -s \"$timer_done\" ]; then kill \"$child\" 2>/dev/null || true; fi ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; : >\"$timer_stop\"; kill \"$watchdog\" 2>/dev/null || true; wait \"$watchdog\" 2>/dev/null || true; wait \"$timer_supervisor\" 2>/dev/null || true; rm -f \"$payload\" \"$timer_stop\" \"$timer_done\""
let noOp = stdinDrainingHookNoOpShellCommand
return [
"cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
Expand Down
13 changes: 10 additions & 3 deletions CLI/CMUXCLI+SemanticNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ extension CMUXCLI {
let fields = payload.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard fields.count >= 3 else { throw CLIError(message: String(localized: "cli.notification.invalidPayload", defaultValue: "Invalid notification payload")) }
let meta = fields.count > 3 ? fields[3].split(separator: ";").map(String.init) : []
if source == "codex", kind == .approvalRequested,
Self.semanticAttentionContext(rawObject, source: source).requestIdentity == nil,
let approval = meta.first(where: { $0.hasPrefix("a=") }),
approval.range(of: "^a=[0-9a-f]{24}\\.[0-9a-f]{24}$", options: .regularExpression) != nil {
return "notify_target_async \(workspaceId) \(surfaceId) \(payload)"
}
Comment thread
cursor[bot] marked this conversation as resolved.
let category = meta.first { $0.hasPrefix("c=") }.map { String($0.dropFirst(2)) }
?? (kind == .turnCompleted ? "turn-complete" : "other")
let notification = AgentJournalNotification(title: fields[0], subtitle: fields[1], body: fields[2],
Expand All @@ -26,7 +32,7 @@ extension CMUXCLI {
kind: AgentJournalEventKind, rawObject: [String: Any]?, notification: AgentJournalNotification,
pendingWork: Bool = false, isSubagent: Bool = false
) throws -> String {
var context = Self.semanticAttentionContext(rawObject)
var context = Self.semanticAttentionContext(rawObject, source: source)
var notification = notification
switch kind {
case .errorReported, .messagePublished: notification.category = "other"
Expand All @@ -47,7 +53,7 @@ extension CMUXCLI {
return "agent_journal_append \(String(decoding: data, as: UTF8.self))"
}

static func semanticAttentionContext(_ object: [String: Any]?) -> AgentAttentionContext {
static func semanticAttentionContext(_ object: [String: Any]?, source: String? = nil) -> AgentAttentionContext {
func identifier(_ keys: [String]) -> String? {
for key in keys {
if let value = object?[key] as? String, !value.isEmpty { return value }
Expand All @@ -59,7 +65,8 @@ extension CMUXCLI {
return AgentAttentionContext(
eventIdentity: identifier(["event_id", "eventId", "message_id", "uuid"]),
turnIdentity: identifier(["turn_id", "turnId"]),
requestIdentity: identifier(["tool_use_id", "toolUseId", "toolUseID", "tool_call_id", "toolCallId", "request_id", "requestId"]))
requestIdentity: (source == "codex" ? CodexApprovalNotificationIdentity.nativeRequestID(in: object) : nil)
?? identifier(["tool_use_id", "toolUseId", "toolUseID", "tool_call_id", "toolCallId", "request_id", "requestId"]))
}

static func semanticOccurredAtMs(_ object: [String: Any]?) -> Int64? {
Expand Down
70 changes: 48 additions & 22 deletions CLI/FeedEventClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,16 @@ struct FeedEventClassification: Equatable {
/// A tool COMPLETED for an agent whose approval prompts notify via
/// ``notifiesNativeApprovalPrompt`` — execution strictly follows any
/// approval, so the prompt resolved (approved by the user or by the
/// agent's own auto-reviewer) and the bridge clears the pane's stale
/// notifications. Only tool COMPLETION qualifies: pre-tool events fire
/// agent's own auto-reviewer) and the bridge resolves the correlated
/// approval notification. Only tool COMPLETION qualifies: pre-tool events fire
/// when the agent intends to run a tool, with no ordering guarantee
/// against the approval-prompt hook, so clearing there could erase a
/// just-raised prompt while the agent is still blocked.
///
/// The clear is deliberately pane-wide and uncorrelated with any single
/// request: notifications carry no request identity anywhere in cmux,
/// and every agent integration clears the same way on progress signals —
/// Claude's `session-start`/`prompt-submit`/`pre-tool-use` hooks, the
/// generic `.approvalResponse` action (Hermes' resolved native
/// approvals), and codex's own `prompt-submit` hook (which also clears
/// deny-without-further-tools residue at the next turn). Pane
/// notifications are attention signals; agent progress in the pane makes
/// them stale as a set.
/// Newer Codex payloads carry an approval/call id; older payloads fall back
/// to a bounded session/turn/tool/input identity. The app-side coordinator
/// marks repeated derived identities ambiguous, so one completion cannot
/// settle multiple identical requests.
let clearsNativeApprovalPrompt: Bool
}

Expand Down Expand Up @@ -198,8 +193,8 @@ struct FeedEventClassifier {
case .toolEnd:
// A completed tool ran, and execution strictly follows any
// approval — so this is the earliest progress signal that can
// safely clear a resolved native approval prompt (approved by
// the user or by the agent's own auto-reviewer). Scoped to
// safely resolve its correlated native approval prompt (approved
// by the user or by the agent's own auto-reviewer). Scoped to
// sources that raise those prompts so other agents' tool
// telemetry never touches the notification queue.
return FeedEventClassification(
Expand Down Expand Up @@ -436,14 +431,14 @@ struct FeedEventClassifier {

/// Builds the pane-attention V1 socket command a classified feed event
/// carries — the `needs-permission`-gated `notify_target_async` for a
/// native approval prompt, or the pane-scoped `clear_notifications` for
/// a resolved one. Pure so the exact wire command (UUID gating, payload
/// shape, gate meta) is unit-testable; the CLI feed hook sends the
/// native approval prompt, or the correlated `clear_notifications` for a
/// resolved one. Pure so the exact wire command (UUID gating, payload
/// shape, gate/correlation meta) is unit-testable; the CLI feed hook sends the
/// returned line request/response and awaits the app's acknowledgement.
///
/// Returns `nil` when the classification carries no attention side
/// effect or when either identity is missing/not a UUID: the command is
/// advisory and must never fail the hook.
/// Returns `nil` when the classification carries no attention side effect,
/// when targets are invalid, or when an exact Codex completion lacks its
/// identity. A legacy prompt may still use the pane-scoped notify form.
///
/// The notification body deliberately names only the TOOL — mirroring
/// the in-app Feed approval banner (`feed.notification.permission.body`)
Expand All @@ -457,7 +452,9 @@ struct FeedEventClassifier {
workspaceId: String?,
surfaceId: String?,
agentID: String = "codex",
includeAgentContext: Bool = false
includeAgentContext: Bool = false,
source: String? = nil,
approvalIdentity: CodexApprovalNotificationIdentity? = nil
) -> String? {
guard classification.notifiesNativeApprovalPrompt
|| classification.clearsNativeApprovalPrompt else { return nil }
Expand All @@ -466,7 +463,28 @@ struct FeedEventClassifier {
let surfaceRaw = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines),
let surfaceUUID = UUID(uuidString: surfaceRaw)
else { return nil }
// A Codex completion is correlated by an exact identity. Other native
// approval producers retain the historical pane-scoped command shape;
// callers that omit `source` are treated as Codex for compatibility
// with the strict completion path.
let requiresCorrelatedIdentity = source?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() == "codex" || source == nil
// A hook payload is bounded, but a single tool-name field could still
// consume nearly the entire budget and force large socket/UI copies.
// Keep attention lines small and predictable on the synchronous path.
guard !classification.clearsNativeApprovalPrompt
|| !requiresCorrelatedIdentity
|| approvalIdentity != nil else {
return nil
}
if classification.clearsNativeApprovalPrompt {
if let approvalIdentity {
return "clear_notifications --tab=\(workspaceUUID.uuidString) --panel=\(surfaceUUID.uuidString) \(approvalIdentity.resolutionOptions)"
}
// Legacy non-Codex producers have no exact approval identity and
// historically clear their own pane-scoped prompt.
guard !requiresCorrelatedIdentity else { return nil }
return "clear_notifications --tab=\(workspaceUUID.uuidString) --panel=\(surfaceUUID.uuidString)"
}
let subtitle = String(
Expand All @@ -487,7 +505,14 @@ struct FeedEventClassifier {
)
}
let meta: String?
if includeAgentContext {
if let approvalIdentity {
meta = AgentHookNotifyCategory.needsPermission.metaSegment(
pending: false,
approvalID: approvalIdentity.approvalID,
approvalIDIsDerived: !approvalIdentity.isAuthoritative,
approvalSource: "feed"
)
} else if includeAgentContext {
meta = AgentHookNotifyCategory.needsPermission.metaSegment(
pending: false,
agentID: agentID
Expand All @@ -507,7 +532,8 @@ struct FeedEventClassifier {
/// tool names are payload-controlled input, so normalize them the same
/// way `notificationPayload` sanitizes its fields.
private static func attentionNotificationField(_ value: String) -> String {
value
let bounded = String(value.prefix(240))
return bounded
.components(separatedBy: .newlines)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
Loading
Loading