diff --git a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift index 85fa9efa..d660c5b4 100644 --- a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift +++ b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift @@ -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 } } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index be4a2d37..fe14e406 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -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 ` 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 diff --git a/graphcode/Sources/Clients/TitleSuggestionClient.swift b/graphcode/Sources/Clients/TitleSuggestionClient.swift index 27f039d7..9d7b4cfb 100644 --- a/graphcode/Sources/Clients/TitleSuggestionClient.swift +++ b/graphcode/Sources/Clients/TitleSuggestionClient.swift @@ -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] } diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index 396eb43a..fbedb5c6 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -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)