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 GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ public enum SummaryModelWriter {
case .pi:
return ["pi", "-p", "--no-tools", "--no-session", prompt] + model
case .nod:
// The runtime's one-shot print mode — NodRuntime/README.md.
return ["graphcode-nod", "-p", prompt] + model
// The runtime's one-shot print mode — NodRuntime/README.md. By its path inside the
// app: a bare `graphcode-nod` is not on the login shell's PATH, so every rewrite failed.
let executable = NodRuntimeLocator.binaryURL()?.path ?? "graphcode-nod"
return [executable, "-p", prompt] + model
}
}

Expand Down
11 changes: 10 additions & 1 deletion GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,20 @@ public enum ZmxSessionLauncher {
let prefix = exports.isEmpty ? "" : "env \(exports)"
// `$0` has to be something, and it shows up in error messages — name it after us.
return [
"/bin/zsh", "-i", "-l", "-c", "exec \(prefix)\(command) \"$@\"\(scriptSuffix)",
"/bin/zsh", "-i", "-l", "-c",
"exec \(prefix)\(shellWord(command)) \"$@\"\(scriptSuffix)",
"graphcode",
] + arguments
}

/// A bare name like `claude` stays as typed, so every CLI launch is unchanged; a path that
/// needs it — Nod's runtime inside an app whose name has a space — is single-quoted.
static func shellWord(_ word: String) -> String {
let plain = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "/._-+,:@%="))
let isPlain = !word.isEmpty && word.unicodeScalars.allSatisfy(plain.contains)
return isPlain ? word : PresenceHooks.singleQuoted(word)
}

/// `zmx get <name> <key>` reads a per-session label. This is the channel a backend's
/// own lifecycle hooks report presence through — a Claude Code hook running
/// `zmx set "$ZMX_SESSION" presence=busy` is what makes a reading `.reported` rather
Expand Down
5 changes: 4 additions & 1 deletion graphcode/Sources/Clients/TitleSuggestionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ extension TitleSuggestionClient: DependencyKey {
"exec codex exec --dangerously-bypass-approvals-and-sandbox \"$\(promptVariable)\""
case .openCode: command = "exec opencode run \"$\(promptVariable)\""
case .pi: command = "exec pi -p --no-tools --no-session \"$\(promptVariable)\""
case .nod: return nil
// Never a PATH lookup: Nod ships inside the app and is not on the login shell's PATH.
case .nod:
guard let binary = NodRuntimeLocator.binaryURL() else { return nil }
command = "exec \(PresenceHooks.singleQuoted(binary.path)) -p \"$\(promptVariable)\""
}
return ["/bin/zsh", "-i", "-l", "-c", command]
}
Expand Down
46 changes: 46 additions & 0 deletions graphcode/Tests/NodLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,52 @@ struct NodLaunchArgumentTests {
try String(contentsOf: file, encoding: .utf8) == "Every paid route goes through UsageGate")
#expect(NodRuntimeLocator.writeGoal(of: LoopNode(title: "t", backend: .nod)) == nil)
}

/// Nod is not on the login shell's PATH, so a headless call that names it bare never
/// runs: a blank-titled Nod loop stayed "NewNode", and every summary rewrite failed.
@Test
func titleRequestsRunTheBundledRuntimeByPath() throws {
NodRuntimeLocator.binaryOverride = URL(
fileURLWithPath: "/Apps/Graph Code.app/nod/graphcode-nod")
NodRuntimeLocator.rampOverride = true
defer {
NodRuntimeLocator.binaryOverride = nil
NodRuntimeLocator.rampOverride = nil
}
let title = try #require(TitleSuggestionClient.invocation(for: .nod)?.last)
#expect(
title == "exec '/Apps/Graph Code.app/nod/graphcode-nod' -p \"$GRAPHCODE_TITLE_PROMPT\"")
}

@Test
func summaryRewritesRunTheBundledRuntimeByPath() {
NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/Apps/nod/graphcode-nod")
NodRuntimeLocator.rampOverride = true
defer {
NodRuntimeLocator.binaryOverride = nil
NodRuntimeLocator.rampOverride = nil
}
let summary = SummaryModelWriter.invocation(forBackend: .nod, prompt: "p")
#expect(Array(summary.prefix(3)) == ["/Apps/nod/graphcode-nod", "-p", "p"])
}

/// The login shell splits an unquoted path at its spaces, so a runtime inside an app
/// named with one never launched; a bare CLI name is still typed exactly as before.
@Test
func aRuntimePathWithASpaceSurvivesTheLoginShell() {
let spaced = ZmxSessionLauncher.loginShellInvocation(
of: "/Apps/Graph Code.app/nod/graphcode-nod", arguments: ["-p", "x"])
#expect(spaced[4] == "exec '/Apps/Graph Code.app/nod/graphcode-nod' \"$@\"")
let bare = ZmxSessionLauncher.loginShellInvocation(of: "claude", arguments: [])
#expect(bare[4] == "exec claude \"$@\"")
}

@Test
func noRuntimeMeansNoTitleRequest() {
NodRuntimeLocator.rampOverride = false
defer { NodRuntimeLocator.rampOverride = nil }
#expect(TitleSuggestionClient.invocation(for: .nod) == nil)
}
}

private func failure(of result: Result<Void, NodControlClient.Failure>)
Expand Down
Loading