Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions docs-ai/073-codex-daemon-caller-identity/000-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Observed impact for a Codex TUI typed by hand (verified live with Codex 0.158.0)

### Non-goals

- Log-based working/idle detection for daemon-mode Codex. The screen fallback already
covers it (#838). The mapper built here can back that later.
- Log-based working/idle detection for daemon-mode Codex. (Corrected: delivered as a
stacked follow-up that reuses the mapper; see 002.)
- Fixing the other inherited variables (`PROWL_WORKTREE_PATH`, `SSH_AUTH_SOCK`, …).
- Forcing `--no-daemon` on user-typed commands.

Expand Down Expand Up @@ -110,3 +110,5 @@ The spike that validated the session-log mapping lives on branch
`spike/codex-daemon-log-detection` (`scripts/spikes/codex-daemon-map.py`).

## Amendments

- Updated 2026-09-29: daemon-mode Codex log detection reuses the thread mapper — see [002-daemon-log-detection.md](002-daemon-log-detection.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 073.002 — Daemon-Mode Codex Log Detection

## Context

`CodexLogProvider` found rollouts through the open files of the pane's Codex TUI. A TUI
attached to Codex's shared daemon holds none, so the state machine reported
`screen.noLiveTurn` and relied on the screen alone. That is how the 2026-09-28 symptom
(#838) became visible: the screen rules missed a Codex 0.158 footer and nothing else knew
the turn was open.

## Change

- `CodexDaemonThreadMapper.binding(surfaceID:daemonPID:tuiStartedAt:)` returns the rollouts
a pane drives: the root thread of its newest indexed submit plus every descendant the
daemon holds open. The session log must belong to the current TUI process (its header
within 120 s after the process start), so a log left by an earlier TUI cannot bind.
- The rollout index now honors the live boundary of forked rollouts (client ids and
`task_started` count only after the rollout's own `thread_settings_applied`) and records
the byte offset of the live `task_started` that precedes each client id.
- `CodexLogProvider` falls back to that binding when the TUI's inventory is complete and
holds no rollout. It finds the daemon through `daemon.pid` under the TUI's `CODEX_HOME`
and checks that the pid is still the managed daemon. A newly discovered bound rollout
starts at that turn offset instead of its end, because binding happens only after the
turn's user item is written, which is after `task_started`.
- `AgentDetectionCoordinator` passes its pane's surface id to the provider.

Measured before the change: `/fork` in Codex 0.158 writes only the fork's own items, and a
forked subagent copies `task_started` but not user items before its live boundary.

## Refs

PR #840 (stacked on #839).

## Current state

Live acceptance in a Debug instance with an isolated `CODEX_HOME` daemon, two hand-typed
Codex panes in one cwd:

| Scenario | `detection_reason` |
| --- | --- |
| Before the first submit | `screen.noLiveTurn` (unbound) |
| Concurrent turns in both panes | each `log.openWork`, then `log.turnEnded` at its own end |
| Subagent running while the parent waits | `log.openWork` throughout |
| `/new` then a new turn | `log.openWork` on the new thread |
| Steered message during a turn | `log.openWork` throughout |
| Restart with `codex resume` of an older thread, then a turn | `log.openWork` (live offset) |
| Esc interrupt | `log.turnEnded` |
2 changes: 1 addition & 1 deletion docs-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,4 @@ agent-facing manual for that).
| 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default |
| 071 | [git-environment-recovery](071-git-environment-recovery/000-plan.md) | 2026-09-20 | Working Git selection, safe repository classification, recovery guidance, and plain-folder Shelf entry |
| 072 | [workspace-editing](072-workspace-editing/000-plan.md) | 2026-09-25 | Edit a workspace after creation: metadata, member repositories (add, remove, reorder, roles) through one editor sheet reached from the sidebar, detail view, Settings, palette, and menu |
| 073 | [codex-daemon-caller-identity](073-codex-daemon-caller-identity/000-plan.md) | 2026-09-29 | CLI callers under Codex's shared app-server daemon: cut the ancestry at the daemon and map `CODEX_THREAD_ID` to the driving pane through the TUI session log; `prowl list` reports the caller |
| 073 | [codex-daemon-caller-identity](073-codex-daemon-caller-identity/000-plan.md) | 2026-09-29 | CLI callers under Codex's shared app-server daemon: cut the ancestry at the daemon and map `CODEX_THREAD_ID` to the driving pane through the TUI session log; `prowl list` reports the caller; the same mapping restores daemon-mode Codex log detection |
9 changes: 9 additions & 0 deletions docs/components/agent-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ at a `~/.grok/` install (so Cursor's own `agent` entrypoint stays Cursor).
screen. Attribution is heuristic: switching to an old quiet chat before entering
a new prompt can temporarily retain the previous log candidate.

A `codex` typed by hand in Codex 0.157+ attaches to a shared background daemon
that owns every session log, so the TUI process holds none. Prowl then reads the
daemon's logs for the thread this pane's Codex last submitted to, identified
through the pane's Codex session log (see
[cli identity](cli.md#identity-which-pane-am-i)), plus that thread's subagents.
Until the pane's Codex submits its first message, and for a moment after each
new submit, detection falls back to the screen. A Codex launched from an Agent
Profile or with `--no-daemon` owns its logs and is read directly.

Claude reads the detected PID's native registry under its configured root
(`~/.claude/sessions` by default). `busy` and `shell` mean **Working**, including
assigned child and background shell work; `waiting` means **Blocked**, and `idle`
Expand Down
6 changes: 1 addition & 5 deletions supacode/CLIService/CLICommandContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,8 @@ nonisolated struct CallerPane: Sendable, Equatable {
func belongs(to generation: AgentProcessGeneration) -> Bool {
if processAncestry.contains(generation) { return true }
guard let codexSessionStartedAt else { return false }
let delay = codexSessionStartedAt.timeIntervalSince(generation.startedAt)
return delay >= -1 && delay <= Self.codexSessionStartWindow
return CodexTUISessionLog.belongs(sessionStartedAt: codexSessionStartedAt, toProcessStartedAt: generation.startedAt)
}

/// A TUI writes its session header during startup; a later header belongs to a later TUI.
static let codexSessionStartWindow: TimeInterval = 120
}

/// The caller's ancestry up to, not including, a Codex managed daemon.
Expand Down
107 changes: 95 additions & 12 deletions supacode/CLIService/CodexDaemonThreadMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ nonisolated struct CodexDaemonRollout: Sendable, Equatable {
let id: String
let parentID: String?
var clientIDs: [String]
var path = ""
/// A fork copies history before its own `thread_settings_applied`; that part is not live.
var isForked = false
/// Byte offset of the live `task_started` line that precedes each client message id.
var turnStartOffsets: [String: UInt64] = [:]
}

/// The rollouts a pane's Codex TUI currently drives through the daemon (docs-ai 073.002).
nonisolated struct CodexDaemonBinding: Sendable, Equatable {
let rootID: String
/// The root thread and every descendant the daemon holds open.
let paths: [String]
/// Where reading the bound rollout starts: the turn that holds the pane's newest submit.
let liveOffsets: [String: UInt64]
}

/// The submits of the TUI that last started a pane's session log.
Expand Down Expand Up @@ -40,6 +54,8 @@ actor CodexDaemonThreadMapper {
var offset: UInt64
var pending = Data()
var headerRead = false
var live = true
var lastTurnStart: UInt64?
var rollout: Rollout?
}

Expand Down Expand Up @@ -72,22 +88,59 @@ actor CodexDaemonThreadMapper {
return Self.resolve(threadID: threadID, rollouts: rollouts, logs: sessionLogs())
}

/// What the pane's current Codex TUI drives, or nil before its first indexed submit. The
/// session log must belong to the TUI process that started at `tuiStartedAt`.
func binding(surfaceID: UUID, daemonPID: pid_t, tuiStartedAt: Date) -> CodexDaemonBinding? {
let url = CodexTUISessionLog.url(for: surfaceID, in: sessionLogDirectory)
guard let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize, size <= sessionLogLimit,
let data = try? Data(contentsOf: url),
let log = Self.parseSessionLog(data, surfaceID: surfaceID),
CodexTUISessionLog.belongs(sessionStartedAt: log.startedAt, toProcessStartedAt: tuiStartedAt),
!log.submits.isEmpty,
let paths = openFilePaths(daemonPID),
let rollouts = refreshRollouts(paths.filter(Self.isRolloutPath))
else { return nil }
return Self.binding(for: log, rollouts: rollouts)
}

// MARK: - Resolution

static func resolve(threadID: String, rollouts: [Rollout], logs: [SessionLog]) -> CodexThreadPane? {
static func binding(for log: SessionLog, rollouts: [Rollout]) -> CodexDaemonBinding? {
let byID = Dictionary(rollouts.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let owner = owners(rollouts)
guard let submit = log.submits.last(where: { owner[$0.clientID] != nil }),
let bound = owner[submit.clientID].flatMap({ byID[$0] })
else { return nil }
let rootID = root(of: bound.id, in: byID)
let family = rollouts.filter { root(of: $0.id, in: byID) == rootID }
return CodexDaemonBinding(
rootID: rootID,
paths: family.map(\.path).sorted(),
liveOffsets: bound.turnStartOffsets[submit.clientID].map { [bound.path: $0] } ?? [:]
)
}

private static func owners(_ rollouts: [Rollout]) -> [String: String] {
var owner: [String: String] = [:]
for rollout in rollouts {
for clientID in rollout.clientIDs { owner[clientID] = rollout.id }
}
func root(_ id: String) -> String {
var current = id
var visited: Set<String> = []
while let parent = byID[current]?.parentID, visited.insert(current).inserted {
current = parent
}
return current
return owner
}

private static func root(of id: String, in byID: [String: Rollout]) -> String {
var current = id
var visited: Set<String> = []
while let parent = byID[current]?.parentID, visited.insert(current).inserted {
current = parent
}
return current
}

static func resolve(threadID: String, rollouts: [Rollout], logs: [SessionLog]) -> CodexThreadPane? {
let byID = Dictionary(rollouts.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let owner = owners(rollouts)
func root(_ id: String) -> String { Self.root(of: id, in: byID) }
let target = root(threadID)
let bound = logs.compactMap { log -> (log: SessionLog, submittedAt: Date)? in
guard let submit = log.submits.last(where: { owner[$0.clientID] != nil }),
Expand Down Expand Up @@ -182,25 +235,35 @@ actor CodexDaemonThreadMapper {
} catch {
return nil
}
Self.consume(&cursor)
Self.consume(&cursor, path: path)
next[path] = cursor
}
cursors = next
return next.values.compactMap(\.rollout)
}

private static func consume(_ cursor: inout Cursor) {
private static func consume(_ cursor: inout Cursor, path: String) {
let base = cursor.offset - UInt64(cursor.pending.count)
var start = cursor.pending.startIndex
while let end = cursor.pending[start...].firstIndex(of: 10) {
let line = cursor.pending[start..<end]
let lineOffset = base + UInt64(start - cursor.pending.startIndex)
start = end + 1
if !cursor.headerRead {
cursor.headerRead = true
cursor.rollout = header(line)
cursor.rollout?.path = path
cursor.live = cursor.rollout?.isForked != true
continue
}
if cursor.rollout != nil, let clientID = userMessageClientID(line) {
guard let id = cursor.rollout?.id else { continue }
if !cursor.live {
cursor.live = isLiveBoundary(line, threadID: id)
} else if isTaskStarted(line) {
cursor.lastTurnStart = lineOffset
} else if let clientID = userMessageClientID(line) {
cursor.rollout?.clientIDs.append(clientID)
cursor.rollout?.turnStartOffsets[clientID] = cursor.lastTurnStart
}
}
cursor.pending.removeSubrange(cursor.pending.startIndex..<start)
Expand All @@ -214,7 +277,27 @@ actor CodexDaemonThreadMapper {
else { return nil }
let source = payload["source"] as? [String: Any]
let spawn = (source?["subagent"] as? [String: Any])?["thread_spawn"] as? [String: Any]
return Rollout(id: id, parentID: spawn?["parent_thread_id"] as? String, clientIDs: [])
return Rollout(
id: id, parentID: spawn?["parent_thread_id"] as? String, clientIDs: [],
isForked: payload["forked_from_id"] is String)
}

private static func eventPayload(_ line: Data, type: String) -> [String: Any]? {
guard line.range(of: Data("\"\(type)\"".utf8)) != nil,
let record = try? JSONSerialization.jsonObject(with: line) as? [String: Any],
record["type"] as? String == "event_msg",
let payload = record["payload"] as? [String: Any],
payload["type"] as? String == type
else { return nil }
return payload
}

static func isTaskStarted(_ line: Data) -> Bool {
eventPayload(line, type: "task_started") != nil
}

static func isLiveBoundary(_ line: Data, threadID: String) -> Bool {
eventPayload(line, type: "thread_settings_applied")?["thread_id"] as? String == threadID
}

static func userMessageClientID(_ line: Data) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ final class AgentDetectionCoordinator {
private var interactionRevision: UInt64 = 0
private var now: TimeInterval { time() }

init(sample: Sample? = nil, time: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
private let surfaceID: UUID?

init(
surfaceID: UUID? = nil,
sample: Sample? = nil,
time: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
) {
self.surfaceID = surfaceID
sampleOverride = sample
self.time = time
}
Expand Down Expand Up @@ -82,7 +89,7 @@ final class AgentDetectionCoordinator {
self.agent = agent
self.process = process
self.configRoot = configRoot
if agent == .codex, process != nil { logProvider = CodexLogProvider() }
if agent == .codex, process != nil { logProvider = CodexLogProvider(surfaceID: surfaceID) }
if agent == .claude, process != nil { nativeProvider = ClaudeRuntimeProvider() }
}
let expectedRevision = revision
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ extension WorktreeTerminalState {
previous.seen = current.seen
previous.lastChangedAt = current.lastChangedAt
guard previous == current else { return true }
let coordinator = agentDetectionCoordinators[surfaceID] ?? AgentDetectionCoordinator()
let coordinator = agentDetectionCoordinators[surfaceID] ?? AgentDetectionCoordinator(surfaceID: surfaceID)
agentDetectionCoordinators[surfaceID] = coordinator
let process = Self.processGeneration(identified)

Expand Down
Loading
Loading