Skip to content

Commit 691f657

Browse files
scgopiclaude
andcommitted
Merge pull request #349 from scgopi/fix/codex-briefing-fits
Launch Codex loops with their graph briefing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents fbf323a + 1a37930 commit 691f657

5 files changed

Lines changed: 176 additions & 44 deletions

File tree

GraphcodeKit/Sources/Domain/BackendCommand.swift

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,17 +286,15 @@ extension CLISessionBackendKind {
286286
case .copilotCLI:
287287
return sessionName.map { ["--name", $0] } ?? []
288288
case .codex:
289-
// Codex reports only the *end* of a turn, through `notify`, and needs no file to do
290-
// it — just somewhere to write, which is why this is the one backend that takes the
291-
// `zmx` path rather than a path to something graphcode wrote. Its other edge is
289+
// Codex reports only the *end* of a turn, through `notify`. Its other edge is
292290
// covered without asking Codex anything: see `ZmxSessionLauncher.codexPresence`.
293-
return zmxPath.map {
294-
[
295-
"-c",
296-
PresenceHooks.codexNotifyOverride(
297-
zmxPath: $0, sessionsDirectory: sessionsDirectory),
298-
]
299-
} ?? []
291+
// A remote launch has no local hooks file and names the one its ensure wrote on
292+
// the host, which only a remote `sessionsDirectory` distinguishes from a local
293+
// launch whose write failed.
294+
if let hooksFile {
295+
return ["-c", PresenceHooks.codexNotifyOverride(scriptPath: hooksFile.path)]
296+
}
297+
return sessionsDirectory == nil ? [] : ["-c", PresenceHooks.remoteCodexNotifyOverride]
300298
case .openCode:
301299
// Reports through a plugin, which rides in the environment rather than the argv —
302300
// see `presenceEnvironment`.

GraphcodeKit/Sources/Sessions/PresenceHooks.swift

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ public enum PresenceHooks {
5757
directory.appendingPathComponent("notification.sh")
5858
}
5959

60+
/// Codex's `notify` program, a file so the `-c` override that names it stays a path —
61+
/// inlined, the script alone overran the typed line once a briefing rode beside it.
62+
public static var codexNotifyScriptFile: URL {
63+
directory.appendingPathComponent("codex-notify.sh")
64+
}
65+
6066
/// Which of a backend's lifecycle events mean what, in the backend's own event names.
6167
///
6268
/// `nil` for a backend with no hook mechanism at all, which is the honest answer for
@@ -356,6 +362,7 @@ public enum PresenceHooks {
356362
public static let remotePiExtensionExpression = "\"\(remotePiExtensionPath)\""
357363
public static let remoteOpenCodePluginExpression =
358364
"\"$HOME/.graphcode/hooks/opencode-presence.js\""
365+
public static let remoteCodexNotifyScriptExpression = "\"$HOME/.graphcode/hooks/codex-notify.sh\""
359366

360367
/// The remote twin of `SessionIDStore.file(forNodeID:)` — the file the remote
361368
/// `SessionStart` hook wrote, as a shell expression the ensure dial can `cat`.
@@ -424,6 +431,7 @@ public enum PresenceHooks {
424431
public static func write(forBackend backend: CLISessionBackendKind) -> URL? {
425432
guard ZmxLocator.isInstalled else { return nil }
426433
if backend == .pi { return writePiExtension() }
434+
if backend == .codex { return writeCodexNotifyScript() }
427435
guard let json = json(forBackend: backend, zmxPath: ZmxLocator.binaryURL.path)
428436
else { return nil }
429437
let url = file(forBackend: backend)
@@ -469,6 +477,18 @@ public enum PresenceHooks {
469477
}
470478
}
471479

480+
/// Codex's reporter, written where `codexNotifyOverride(scriptPath:)` names it.
481+
private static func writeCodexNotifyScript() -> URL? {
482+
do {
483+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
484+
try codexNotifyScript(zmxPath: ZmxLocator.binaryURL.path)
485+
.write(to: codexNotifyScriptFile, atomically: true, encoding: .utf8)
486+
return codexNotifyScriptFile
487+
} catch {
488+
return nil
489+
}
490+
}
491+
472492
// MARK: - Remote sessions
473493

474494
/// Where the hooks land on a remote host — a `$HOME` expression rather than a path,
@@ -507,6 +527,12 @@ public enum PresenceHooks {
507527
+ " && printf '%s' \(singleQuoted(PiPresenceExtension.remoteSource(zmxPath: "zmx")))"
508528
+ " > \(remotePiExtensionExpression); } 2>/dev/null || true"
509529
}
530+
if backend == .codex {
531+
let script = codexNotifyScript(zmxPath: "zmx", sessionsDirectory: remoteSessionsExpression)
532+
return "{ mkdir -p \"$HOME/.graphcode/hooks\""
533+
+ " && printf '%s' \(singleQuoted(script))"
534+
+ " > \(remoteCodexNotifyScriptExpression); } 2>/dev/null || true"
535+
}
510536
guard backend == .claudeCode else { return nil }
511537
guard
512538
let json = json(
@@ -545,22 +571,34 @@ public enum PresenceHooks {
545571
/// Validated as a real key rather than assumed: `codex --strict-config` rejects an
546572
/// invented field outright ("unknown configuration field") and accepts this one.
547573
///
548-
/// The value is TOML, parsed by Codex out of one argv element. Codex appends its event
549-
/// JSON as a further argument, which `sh -c` puts in `$0`; its thread ID is persisted
550-
/// under the node ID so only that node can resume it after the zmx session disappears.
551-
public static func codexNotifyOverride(
552-
zmxPath: String, sessionsDirectory: String? = nil
553-
) -> String {
574+
/// The value is TOML, parsed by Codex out of one argv element, and names a script file
575+
/// rather than carrying the script: `zmx` types the launch into a `MAX_CANON`-capped line,
576+
/// and the inline script left no room for the briefing, so every Codex loop launched
577+
/// unbriefed. Codex appends its event JSON as a further argument, `$1` to the script.
578+
public static func codexNotifyOverride(scriptPath: String) -> String {
579+
"notify=[\"/bin/sh\",\(tomlString(scriptPath))]"
580+
}
581+
582+
/// The remote twin: only a shell on that host can expand `$HOME`, so `sh -c` does it
583+
/// there and forwards the event JSON (its `$0`) as the script's `$1`.
584+
public static let remoteCodexNotifyCommand =
585+
"exec /bin/sh \(remoteCodexNotifyScriptExpression) \"$0\""
586+
587+
public static var remoteCodexNotifyOverride: String {
588+
"notify=[\"/bin/sh\",\"-c\",\(tomlString(remoteCodexNotifyCommand))]"
589+
}
590+
591+
/// What `notify` runs: its thread ID is persisted under the node ID so only that node
592+
/// can resume it after the zmx session disappears, then the turn end is reported.
593+
static func codexNotifyScript(zmxPath: String, sessionsDirectory: String? = nil) -> String {
554594
let sessions = sessionsDirectory ?? localSessionsExpression
555-
let script =
556-
"i=$(printf '%s' \"$0\"|sed -n "
595+
return "i=$(printf '%s' \"$1\"|sed -n "
557596
+ "'s/.*\"thread-id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p'); "
558597
+ "n=\"${ZMX_SESSION#\(SurfaceRef.zmxSessionPrefix)}\"; d=\(sessions); "
559598
+ "if [ -n \"$i\" ]&&[ \"$n\" != \"$ZMX_SESSION\" ];then mkdir -p \"$d\"; "
560599
+ "printf '%s %s %s\\n' \"$(date +%s)\" \"$i\" \"$PWD\" "
561600
+ ">>\"$d/$n.history\" 2>/dev/null; printf %s \"$i\">\"$d/$n.id\"; fi; "
562-
+ "\(singleQuoted(zmxPath)) set \"$ZMX_SESSION\" presence=idle >/dev/null 2>&1; exit 0"
563-
return "notify=[\"/bin/sh\",\"-c\",\(tomlString(script))]"
601+
+ "\(singleQuoted(zmxPath)) set \"$ZMX_SESSION\" presence=idle >/dev/null 2>&1; exit 0\n"
564602
}
565603

566604
/// A TOML basic string. Only the two escapes this can actually produce are handled,

graphcode/Tests/CodexPresenceTests.swift

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,53 +23,103 @@ struct CodexPresenceTests {
2323

2424
@Test
2525
func theTurnEndIsReportedThroughTheOneChannelCodexHas() {
26-
let override = PresenceHooks.codexNotifyOverride(zmxPath: zmx)
26+
let script = PresenceHooks.codexNotifyScript(zmxPath: zmx)
2727

28-
#expect(override.hasPrefix("notify=["))
29-
#expect(override.contains("presence=idle"))
28+
#expect(script.contains("presence=idle"))
3029
// Same session-owned label store the other two backends report into, so one reader
3130
// serves all three.
32-
#expect(override.contains(#"$ZMX_SESSION"#))
33-
#expect(override.contains("thread-id"))
34-
#expect(override.contains(".history"))
35-
#expect(override.contains(".id"))
31+
#expect(script.contains(#"$ZMX_SESSION"#))
32+
#expect(script.contains("thread-id"))
33+
#expect(script.contains(".history"))
34+
#expect(script.contains(".id"))
35+
#expect(script.contains("'\(zmx)'"))
3636
}
3737

3838
@Test
3939
func theOverrideIsValidTOMLForAnAwkwardPath() {
40-
// The value is TOML parsed out of one argv element, so the inner quotes around
41-
// `$ZMX_SESSION` have to survive as escapes rather than closing the string early.
42-
let override = PresenceHooks.codexNotifyOverride(zmxPath: "/Users/o'brien/bin/zmx")
43-
44-
#expect(override.contains(#"\"$ZMX_SESSION\""#))
45-
// Two layers, and the doubled backslash is both of them doing their job: shell
46-
// quoting turns the apostrophe into `'\''`, then TOML escapes that backslash to
47-
// `\\`. Codex decodes the TOML back to `'\''`, which is what the shell must see.
48-
#expect(override.contains(#"o'\\''brien"#))
49-
// Three array elements: the program, its flag, and the script.
50-
#expect(override.hasPrefix(#"notify=["/bin/sh","-c",""#))
51-
#expect(override.hasSuffix("]"))
40+
// The value is TOML parsed out of one argv element, so a quote in the script's path
41+
// has to survive as an escape rather than closing the string early.
42+
let override = PresenceHooks.codexNotifyOverride(scriptPath: #"/Users/o"brien/codex-notify.sh"#)
43+
44+
#expect(override == #"notify=["/bin/sh","/Users/o\"brien/codex-notify.sh"]"#)
45+
// The remote form's `$HOME` and `$0` stay escaped for the shell on the host to expand.
46+
#expect(
47+
PresenceHooks.remoteCodexNotifyOverride
48+
== #"notify=["/bin/sh","-c","exec /bin/sh \"$HOME/.graphcode/hooks/codex-notify.sh\" \"$0\""]"#
49+
)
50+
#expect(PresenceHooks.codexNotifyScript(zmxPath: "/Users/o'brien/zmx").contains(#"o'\''brien"#))
51+
}
52+
53+
@Test
54+
func theNotifyScriptBanksTheThreadAndReportsIdleWhenCodexRunsIt() async throws {
55+
// Run the way Codex runs it: the script by path, the event JSON appended as one more
56+
// argument — then again through the remote form's `$HOME` hop.
57+
let root = FileManager.default.temporaryDirectory
58+
.appendingPathComponent("codex-notify-\(UUID().uuidString)", isDirectory: true)
59+
defer { try? FileManager.default.removeItem(at: root) }
60+
let hooks = root.appendingPathComponent(".graphcode/hooks", isDirectory: true)
61+
try FileManager.default.createDirectory(at: hooks, withIntermediateDirectories: true)
62+
let calls = root.appendingPathComponent("zmx-calls")
63+
let fakeZmx = root.appendingPathComponent("zmx")
64+
try "#!/bin/sh\necho \"$@\" >> '\(calls.path)'\n".write(
65+
to: fakeZmx, atomically: true, encoding: .utf8)
66+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeZmx.path)
67+
let sessions = root.appendingPathComponent("sessions", isDirectory: true)
68+
let script = hooks.appendingPathComponent("codex-notify.sh")
69+
try PresenceHooks.codexNotifyScript(
70+
zmxPath: fakeZmx.path, sessionsDirectory: PresenceHooks.singleQuoted(sessions.path)
71+
).write(to: script, atomically: true, encoding: .utf8)
72+
let nodeID = UUID().uuidString
73+
let event = #"{"type":"agent-turn-complete","thread-id":"t-42"}"#
74+
let environment = [
75+
"HOME": root.path, "PATH": "/usr/bin:/bin", "ZMX_SESSION": "graphcode-\(nodeID)",
76+
]
77+
78+
#expect(try await run(["/bin/sh", script.path, event], environment: environment) == 0)
79+
#expect(
80+
try String(contentsOf: sessions.appendingPathComponent("\(nodeID).id"), encoding: .utf8)
81+
== "t-42")
82+
#expect(
83+
try await run(
84+
["/bin/sh", "-c", PresenceHooks.remoteCodexNotifyCommand, event], environment: environment)
85+
== 0)
86+
let reports = try String(contentsOf: calls, encoding: .utf8)
87+
#expect(reports == String(repeating: "set graphcode-\(nodeID) presence=idle\n", count: 2))
88+
}
89+
90+
private func run(_ argv: [String], environment: [String: String]) async throws -> Int32 {
91+
let process = Process()
92+
process.executableURL = URL(fileURLWithPath: argv[0])
93+
process.arguments = Array(argv.dropFirst())
94+
process.environment = environment
95+
return try await withCheckedThrowingContinuation { continuation in
96+
process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) }
97+
do { try process.run() } catch { continuation.resume(throwing: error) }
98+
}
5299
}
53100

54101
@Test
55102
func codexTakesTheFlagAndNothingMeantForTheOthers() {
56103
let arguments = CLISessionBackendKind.codex.presenceArguments(
57-
hooksFile: URL(fileURLWithPath: "/tmp/hooks.json"),
104+
hooksFile: URL(fileURLWithPath: "/tmp/codex-notify.sh"),
58105
sessionName: "graphcode-A", zmxPath: zmx)
59106

60-
#expect(arguments.first == "-c")
61-
#expect(arguments.count == 2)
107+
#expect(arguments == ["-c", #"notify=["/bin/sh","/tmp/codex-notify.sh"]"#])
62108
// Codex has no `--settings` to layer hooks into and no `--name` to label a session.
63109
#expect(!arguments.contains("--settings"))
64110
#expect(!arguments.contains("--name"))
65111
}
66112

67113
@Test
68114
func nowhereToReportMeansNoFlagRatherThanABrokenOne() {
69-
// A machine with no zmx cannot accept a presence or resume-ID report.
115+
// A machine with no zmx writes no script, and a local launch must not fall back to
116+
// naming the remote one.
70117
#expect(
71118
CLISessionBackendKind.codex.presenceArguments(
72119
hooksFile: nil, sessionName: "graphcode-A", zmxPath: nil) == [])
120+
#expect(
121+
CLISessionBackendKind.codex.presenceArguments(
122+
hooksFile: nil, sessionName: "graphcode-A", zmxPath: zmx) == [])
73123
}
74124

75125
@Test
@@ -86,6 +136,7 @@ struct CodexPresenceTests {
86136
#expect(command.contains("notify="))
87137
#expect(command.contains("thread-id"))
88138
#expect(command.contains("$HOME/.graphcode/sessions"))
139+
#expect(command.contains("codex-notify.sh"))
89140
#expect(!command.contains(ZmxLocator.binaryURL.path))
90141
}
91142

graphcode/Tests/RemoteLoopSurvivalTests.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,11 @@ struct RemoteLoopSurvivalTests {
437437
.resumeCommand(settings: GraphcodeSettings(), remoteSettingsPath: nil, isRemote: true)?
438438
.joined(separator: " ")
439439
#expect(codex?.contains(#"resume "$GRAPHCODE_RESUME_ID""#) == true)
440-
#expect(codex?.contains("$HOME/.graphcode/sessions") == true)
440+
// The notifier is a script the restore's hooks write puts on the host.
441+
#expect(codex?.contains("$HOME/.graphcode/hooks/codex-notify.sh") == true)
442+
#expect(
443+
PresenceHooks.remoteWriteFragment(forBackend: .codex)?
444+
.contains("$HOME/.graphcode/sessions") == true)
441445
}
442446

443447
// MARK: - Remote presence hooks

graphcode/Tests/ZmxSessionLauncherTests.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,47 @@ struct ZmxSessionLauncherTests {
540540
/// The probe's own failures, kept out of the suite body above only because swiftlint's
541541
/// `type_body_length` is at its limit there.
542542
extension ZmxSessionLauncherTests {
543+
@Test
544+
func aCodexLoopLaunchesBriefedAtEveryGoalLength() throws {
545+
// Codex's `notify` override carried its whole reporter script inline, so its launch
546+
// was ~650 bytes before any goal and the briefing never fit: every Codex loop, local
547+
// or remote, short goal or long, launched with no idea it was in a graph. The medium
548+
// goal is sized from the measured briefed baseline so it fills the line to its edge.
549+
try #require(ZmxLocator.isInstalled)
550+
let settings = GraphcodeSettings(briefsSessionsAboutTheGraph: true)
551+
let budget = ZmxSessionLauncher.maximumTypedCommandBytes
552+
let remote = "ssh://someone@box/~/project"
553+
for projectPath in ["/tmp", remote] {
554+
let briefing =
555+
projectPath == remote
556+
? RemoteGraphAccess.briefingPath(forProjectPath: remote)
557+
: SessionBriefing.directory(forProjectPath: projectPath)
558+
.appendingPathComponent(SessionBriefing.fileName).path
559+
func launch(_ goal: String) throws -> [String] {
560+
let node = LoopNode(
561+
title: "Codex", loopType: .goalBased, goal: GoalSpec(summary: goal), backend: .codex)
562+
defer { NodeMemory.remove(projectPath: projectPath, nodeID: node.id) }
563+
return try #require(
564+
ZmxSessionLauncher.arguments(forNode: node, projectPath: projectPath, settings: settings))
565+
}
566+
let baseline = try launch("x").reduce(0) { $0 + $1.utf8.count + 3 }
567+
// Measured 235 bytes of room locally with the file; main left none once briefed.
568+
#expect(budget - baseline >= 200, "\(projectPath): \(baseline) bytes before the goal")
569+
let medium = String(repeating: "m", count: max(budget - baseline - 8, 1))
570+
let long = String(repeating: "Resolve the conflict before moving on. ", count: 103)
571+
572+
for (length, goal) in [("short", "Fix the flaky test"), ("medium", medium), ("long", long)] {
573+
let arguments = try launch(goal)
574+
let context = "\(length) goal at \(projectPath)"
575+
#expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments), "\(context)")
576+
#expect(arguments.last?.contains(briefing) == true, "\(context)")
577+
#expect(arguments.contains { $0.hasPrefix("notify=[") }, "\(context)")
578+
}
579+
#expect(try launch(medium).last?.contains(medium) == true, "\(projectPath)")
580+
#expect(try launch(long).last?.contains(NodeMemory.promptFileName) == true, "\(projectPath)")
581+
}
582+
}
583+
543584
@Test
544585
func aMediumGoalMovesToAFileBeforeTheBriefingIsDropped() throws {
545586
// Issue #345: a ~600-byte goal overran the line only once the briefing was added, and

0 commit comments

Comments
 (0)