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
3 changes: 3 additions & 0 deletions docs/components/agent-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ at a `~/.grok/` install (so Cursor's own `agent` entrypoint stays Cursor).
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.
If a previously established daemon binding becomes unavailable, detection falls
back to the screen but retains its log cursors. When the binding returns, it
consumes pending events without replaying earlier turn starts.

Claude reads the detected PID's native registry under its configured root
(`~/.claude/sessions` by default). `busy` and `shell` mean **Working**, including
Expand Down
2 changes: 2 additions & 0 deletions docs/components/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ the submitted prompts there (mode 0600); Prowl deletes the file once the pane's
longer be undone. A pane whose Codex has not submitted anything yet, or whose shell
overrides those variables, cannot be mapped, and caller-scoped commands fail with
`SOURCE_REQUIRED`.
Session logs larger than 64 MiB are not mapped. Restarting Codex in the pane resets its
session log; its first indexed submit can establish the mapping again.
Codex launched from an Agent Profile, or with `--no-daemon`, runs commands inside the pane
and is not affected.

Expand Down
14 changes: 10 additions & 4 deletions supacode/Infrastructure/AgentDetection/CodexLogProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,16 @@ actor CodexLogProvider {
.compactMap { parse($0)?.transcriptPath }
var liveOffsets: [URL: UInt64] = [:]
// An embedded TUI owns its rollouts. A TUI that owns none may drive daemon threads.
if complete, paths.isEmpty, let daemonBinding, let binding = await daemonBinding(process, configRoot) {
paths = binding.paths.map { URL(filePath: $0, directoryHint: .notDirectory) }
for (path, offset) in binding.liveOffsets {
liveOffsets[URL(filePath: path, directoryHint: .notDirectory)] = offset
if complete, paths.isEmpty, let daemonBinding {
if let binding = await daemonBinding(process, configRoot) {
paths = binding.paths.map { URL(filePath: $0, directoryHint: .notDirectory) }
for (path, offset) in binding.liveOffsets {
liveOffsets[URL(filePath: path, directoryHint: .notDirectory)] = offset
}
} else if !cursors.isEmpty {
// A failed lookup does not prove that previously bound work disappeared.
// Suspend authority and keep the cursors until a complete binding returns.
complete = false
}
}
let events = sample(paths: paths, inventoryComplete: complete, liveOffsets: liveOffsets)
Expand Down
9 changes: 9 additions & 0 deletions supacode/Infrastructure/AgentDetection/ProcessDetection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,15 @@ nonisolated enum ProcessDetection {
}

var strings: [String] = []
// Only the executable-path padding may contain extra NUL bytes. Within argv,
// each NUL ends one argument, including an empty one.
for _ in 0..<Int(argc) {
guard position < buffer.count, let end = buffer[position...].firstIndex(of: 0),
let value = String(bytes: buffer[position..<end], encoding: .utf8)
else { return nil }
strings.append(value)
position = end + 1
}
while position < buffer.count {
let start = position
while position < buffer.count, buffer[position] != 0 {
Expand Down
29 changes: 29 additions & 0 deletions supacodeTests/CodexLogProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,35 @@ struct CodexLogProviderTests {
#expect(requested.withLock { $0 } == [configRoot as URL?])
}

@Test func missingDaemonBindingSuspendsWithoutDiscardingProgress() async throws {
let directory = try fixture()
defer { try? FileManager.default.removeItem(at: directory) }
let path = directory.appending(path: "rollout-daemon.jsonl")
let prefix = header("root")
try (prefix + start("t1")).write(to: path, atomically: false, encoding: .utf8)
let started = try #require(ProcessDetection.processStartDate(pid: getpid()))
let process = AgentProcessGeneration(pid: getpid(), startedAt: started)
let available = Mutex(true)
let provider = CodexLogProvider(
startedAt: .distantFuture,
daemonBinding: { _, _ in
guard available.withLock({ $0 }) else { return nil }
return CodexDaemonBinding(
rootID: "root", paths: [path.path], liveOffsets: [path.path: UInt64(prefix.utf8.count)])
})

#expect(startedTurns(await provider.sample(process: process, configRoot: nil)) == ["t1"])
available.withLock { $0 = false }
let missing = await provider.sample(process: process, configRoot: nil)
if case .suspended = missing.first {} else { Issue.record("Missing binding must suspend") }
try append(complete("t1"), to: path)
available.withLock { $0 = true }
let recovered = await provider.sample(process: process, configRoot: nil)
#expect(startedTurns(recovered).isEmpty)
if case .turnEnded("root", "t1") = recovered.last {} else { Issue.record("Completion was lost") }
#expect(await provider.metadataReadCount == 1)
}

@Test func managedDaemonPIDRequiresALiveDaemonProcess() throws {
let home = try fixture()
defer { try? FileManager.default.removeItem(at: home) }
Expand Down
12 changes: 12 additions & 0 deletions supacodeTests/ProcessDetectionSmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ struct ProcessDetectionSmokeTests {
#expect(ProcessDetection.procargs2Environment(buffer) == ["CODEX_THREAD_ID=abc", "PATH=/bin"])
}

@Test func emptyArgumentsDoNotShiftTheEnvironmentBoundary() {
var buffer: [UInt8] = []
withUnsafeBytes(of: Int32(3)) { buffer.append(contentsOf: $0) }
for string in ["/usr/bin/prowl", "", "prowl", "", "list", "CODEX_THREAD_ID=abc", "PATH=/bin"] {
buffer.append(contentsOf: string.utf8)
buffer.append(0)
}

#expect(ProcessDetection.procargs2Argv(buffer) == ["prowl", "", "list"])
#expect(ProcessDetection.procargs2Environment(buffer) == ["CODEX_THREAD_ID=abc", "PATH=/bin"])
}

// The kernel hides the environment of Apple platform binaries (`/bin/sleep`), so the test
// reads its own host process, which, like the `prowl` CLI, is not one.
@Test func readsAProcessLaunchEnvironmentValue() throws {
Expand Down
Loading