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
36 changes: 32 additions & 4 deletions GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,18 @@ extension CLISessionBackendKind {
}
}

/// What to type into a live session to show a loop's new title, for the backends that
/// apply it mid-turn without interrupting. `nil` when the session can't be renamed.
public func renameCommand(displayName: String?, sessionName: String) -> String? {
guard let name = displayName.flatMap(Self.sessionLabel) else { return nil }
switch self {
case .claudeCode: return "/rename \(name)"
case .copilotCLI:
return "/rename \(Self.copilotSessionName(title: name, sessionName: sessionName))"
case .codex, .openCode, .pi, .nod: return nil
}
}

/// OpenCode's TUI has no title flag (`--title` is `opencode run`'s alone), so the title
/// rides to graphcode's plugin, which sets it when the session is created
/// (`OpenCodePresencePlugin`). A resumed session keeps the title it already has.
Expand All @@ -389,17 +401,24 @@ extension CLISessionBackendKind {

/// A loop's title as one line a session picker can show, or `nil` when nothing is left.
public static func sessionLabel(_ title: String) -> String? {
let line = title.components(separatedBy: .newlines).joined(separator: " ")
let line = title.components(separatedBy: CharacterSet.newlines.union(.controlCharacters))
.joined(separator: " ")
.trimmingCharacters(in: .whitespaces)
return line.isEmpty ? nil : line
}

/// Copilot's name for a titled session: the title, then enough of the node's id for
/// `CopilotSessionLog` to find it again. Copilot refuses `--name` beside `--resume`, so
/// this is fixed at first launch and outlives any later rename of the loop.
/// a later rename of the loop can only reach a live session (`renameCommand`).
public static func copilotSessionName(title: String, sessionName: String) -> String {
guard let label = sessionLabel(title) else { return sessionName }
return "\(label) · \(copilotNameSuffix(of: sessionName))"
// Copilot 1.0.96 rejects a name with a double quote or over 100 characters and keeps
// the old one.
let usable = title.filter { $0 != "\"" }
guard let label = sessionLabel(usable) else { return sessionName }
let suffix = " · \(copilotNameSuffix(of: sessionName))"
let fitted = label.prefix(copilotNameLimit - suffix.count)
.trimmingCharacters(in: .whitespaces)
return fitted + suffix
}

/// Whether `recorded`, a `name:` read back out of Copilot's `workspace.yaml`, belongs to
Expand All @@ -413,6 +432,15 @@ extension CLISessionBackendKind {
return unquoted.hasSuffix(" · \(copilotNameSuffix(of: sessionName))")
}

/// `isCopilotSessionName` for a remote host's shell, as a `grep` still to be given its
/// file. The closing bracket's class is YAML's closing quote, whichever one the writer
/// picked.
public static func copilotNameMatcher(for sessionName: String) -> String {
"grep -qE '^name: (\(sessionName)|.* · \(copilotNameSuffix(of: sessionName))[\"'\\'']?)$'"
}

private static let copilotNameLimit = 100

private static func copilotNameSuffix(of sessionName: String) -> String {
let id =
sessionName.hasPrefix("graphcode-")
Expand Down
40 changes: 27 additions & 13 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public actor GraphStore {
/// stays for the `until`-guard and for every test that stubs a bare yes/no.
private let onCheckPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)?
private let onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)?
private let onAwaitRenameWindow: (@Sendable (LoopNode, String?) async -> Bool)?
private let onCaptureScript: (@Sendable (ShellPredicate) async -> String?)?
private let onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)?
private let onReadGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)?
Expand Down Expand Up @@ -403,6 +404,7 @@ public actor GraphStore {
onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil,
onCheckPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? = nil,
onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? = nil,
onAwaitRenameWindow: (@Sendable (LoopNode, String?) async -> Bool)? = nil,
onCaptureScript: (@Sendable (ShellPredicate) async -> String?)? = nil,
onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = nil,
onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil,
Expand Down Expand Up @@ -451,6 +453,7 @@ public actor GraphStore {
self.onEvaluatePredicate = onEvaluatePredicate
self.onCheckPredicate = onCheckPredicate
self.onDeliverMessage = onDeliverMessage
self.onAwaitRenameWindow = onAwaitRenameWindow
self.onCaptureScript = onCaptureScript
self.onReadUsage = onReadUsage
self.onReadActivity = onReadActivity
Expand Down Expand Up @@ -1577,6 +1580,7 @@ public actor GraphStore {
onEvaluatePredicate: onEvaluatePredicate,
onCheckPredicate: onCheckPredicate,
onDeliverMessage: onDeliverMessage,
onAwaitRenameWindow: onAwaitRenameWindow,
onCaptureScript: onCaptureScript,
onAppendMemory: onAppendMemory,
onRemoveMemory: onRemoveMemory,
Expand Down Expand Up @@ -2031,29 +2035,38 @@ public actor GraphStore {
graph.nodes[id: nodeID]?.title = trimmed
}

/// Carries a new title into a live Claude Code session as `/rename`, which takes effect
/// even mid-turn without interrupting it. Without this, a blank-titled loop's session
/// kept its launch-time name for good: the title is suggested only after the session
/// has started. A resolved loop is renamed too: its session often stays up for
/// follow-up questions, and the send's own liveness gate skips one that is gone. Never
/// staged to memory — a name a resume will pass anyway (`--name`) is not something the
/// loop should read at its next wake.
/// Carries a new title into a live session as `/rename`, which takes effect even
/// mid-turn without interrupting it. Without this, a blank-titled loop's session kept
/// its launch-time name for good: the title is suggested only after the session has
/// started. A resolved loop is renamed too: its session often stays up for follow-up
/// questions, and the send's own liveness gate skips one that is gone. Never staged to
/// memory — a rename is not something the loop should read at its next wake.
private func renameSession(of node: LoopNode) {
guard node.backend == .claudeCode, let onDeliverMessage,
let name = SessionNaming.displayName(forTitle: node.title)
.flatMap(CLISessionBackendKind.sessionLabel)
guard let onDeliverMessage,
let command = node.backend.renameCommand(
displayName: SessionNaming.displayName(forTitle: node.title),
sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)
else { return }
let command = "/rename \(name)"
let path = graph.project.path
let window = node.backend == .copilotCLI ? onAwaitRenameWindow : nil
guard subGraphDepth == 0 else {
Task { _ = await onDeliverMessage(node, command, path) }
Task {
if let window, await !window(node, path) { return }
_ = await onDeliverMessage(node, command, path)
}
return
}
let previous = sessionTyping[node.id]?.task
let token = UUID()
let task = Task { [self] in
await previous?.value
_ = await withDeadline(deliveryDeadline) { await onDeliverMessage(node, command, path) }
var open = true
if let window {
open = await window(node, path) && graph.nodes[id: node.id]?.title == node.title
}
if open {
_ = await withDeadline(deliveryDeadline) { await onDeliverMessage(node, command, path) }
}
if sessionTyping[node.id]?.token == token {
sessionTyping.removeValue(forKey: node.id)
}
Expand Down Expand Up @@ -4631,6 +4644,7 @@ public actor GraphStore {
onEvaluatePredicate: onEvaluatePredicate,
onCheckPredicate: onCheckPredicate,
onDeliverMessage: onDeliverMessage,
onAwaitRenameWindow: onAwaitRenameWindow,
onCaptureScript: onCaptureScript,
onReadUsage: onReadUsage,
onReadPresence: onReadPresence,
Expand Down
5 changes: 5 additions & 0 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public actor ProjectRegistry {
private let evaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)?
private let checkPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)?
private let deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)?
private let awaitRenameWindow: (@Sendable (LoopNode, String?) async -> Bool)?
private let captureScript: (@Sendable (ShellPredicate) async -> String?)?
private let readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)?
private let readGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)?
Expand Down Expand Up @@ -135,6 +136,8 @@ public actor ProjectRegistry {
ShellPredicateEvaluator.check,
deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? =
CLISessionBackend.deliverMessage,
awaitRenameWindow: (@Sendable (LoopNode, String?) async -> Bool)? =
CLISessionBackend.awaitRenameWindow,
captureScript: (@Sendable (ShellPredicate) async -> String?)? = ShellPredicateEvaluator.capture,
readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? =
CLISessionBackend.readUsage,
Expand Down Expand Up @@ -183,6 +186,7 @@ public actor ProjectRegistry {
self.evaluatePredicate = evaluatePredicate
self.checkPredicate = checkPredicate
self.deliverMessage = deliverMessage
self.awaitRenameWindow = awaitRenameWindow
self.captureScript = captureScript
self.readUsage = readUsage
self.readGoalVerdict = readGoalVerdict
Expand Down Expand Up @@ -1296,6 +1300,7 @@ public actor ProjectRegistry {
onEvaluatePredicate: evaluatePredicate,
onCheckPredicate: checkPredicate,
onDeliverMessage: deliverMessage,
onAwaitRenameWindow: awaitRenameWindow,
onCaptureScript: captureScript,
onReadUsage: readUsage,
onReadActivity: readActivity,
Expand Down
6 changes: 6 additions & 0 deletions GraphcodeKit/Sources/Sessions/CLISessionBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ extension CLISessionBackend {
await backend(for: node).restart(node, path)
}

/// The rename gate `GraphStore` is wired with — see `ZmxSessionLauncher.awaitRenameWindow`.
public static let awaitRenameWindow: @Sendable (LoopNode, String?) async -> Bool = {
node, projectPath in
await ZmxSessionLauncher.awaitRenameWindow(of: node, projectPath: projectPath)
}

/// The `.message` transport `GraphStore` is wired with — routed through the *target's*
/// backend, since it's the target's session being typed into.
public static let deliverMessage: @Sendable (LoopNode, String, String?) async -> Bool = {
Expand Down
103 changes: 100 additions & 3 deletions GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,101 @@ public enum CopilotSessionLog {
}
}

/// Whether typing into this session now could answer a question instead of reaching
/// the composer: Enter on a permission dialog approves the tool, and an open `ask_user`
/// takes the text as its answer. A session still opening counts too: it can sit at a
/// prompt for minutes, measured at two under `ask`, before its log says anything. Yes
/// when the log can't be read, because a keystroke nobody asked for can wait and a wrong
/// answer can't be taken back. A session that isn't live is a no: the send's own gate
/// skips it.
static func awaitingDecision(of node: LoopNode, projectPath: String?) async -> Bool {
if let projectPath, let location = RemoteProjectLocation.parse(projectPath: projectPath) {
let (succeeded, output) = await ZmxSessionLauncher.collectRemoteOutput(
remoteDecisionInvocation(forNode: node, at: location), location: location)
return remoteDecisionPending(
succeeded: succeeded, output: output, expectingOpening: node.sessionPrompt != nil)
}
guard await ZmxSessionLauncher.sendGate(node) else { return false }
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
guard let directory = directory(forSessionNamed: name) else { return true }
return holdsADecision(
tailLines(ofLogAt: directory.appendingPathComponent("events.jsonl")),
expectingOpening: node.sessionPrompt != nil)
}

/// Newest first, stopping where the running session began: an older run's unanswered
/// dialog is gone. Lines may be cut short, so ids are read as text rather than as JSON.
static func holdsADecision<Line: StringProtocol>(
_ lines: [Line], expectingOpening: Bool = false
) -> Bool {
var sawUserMessage = false
var permissionSettled = false
var finishedCalls: Set<String> = []
for line in lines.reversed() {
if line.contains(#""type":"session.start""#) { return expectingOpening && !sawUserMessage }
if line.contains(#""type":"session.resume""#) { return false }
if line.contains(#""type":"user.message""#) {
sawUserMessage = true
} else if line.contains(#""type":"permission.completed""#) {
permissionSettled = true
} else if line.contains(#""type":"permission.requested""#) {
if !permissionSettled { return true }
} else if line.contains(#""type":"tool.execution_complete""#) {
toolCallID(in: line).map { _ = finishedCalls.insert($0) }
} else if line.contains(#""type":"tool.execution_start""#),
line.contains(#""toolName":"ask_user""#),
let call = toolCallID(in: line), !finishedCalls.contains(call)
{
return true
}
}
return false
}

private static func toolCallID<Line: StringProtocol>(in line: Line) -> String? {
let key = #""toolCallId":""#
guard let start = line.range(of: key)?.upperBound,
let end = line[start...].firstIndex(of: "\"")
else { return nil }
return String(line[start..<end])
}

/// The remote half of `awaitingDecision`. The marker line is the proof the walk ran to
/// the end; its word says what it found.
static func remoteDecisionInvocation(
forNode node: LoopNode, at location: RemoteProjectLocation
) -> [String] {
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
let check = ZmxSessionLauncher.quotedCommand(["zmx", "get", name])
let marker = ZmxSessionLauncher.remoteProbeMarker
let script =
"if \(check) >/dev/null 2>&1; then "
+ "D=''; for d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do "
+ "if \(CLISessionBackendKind.copilotNameMatcher(for: name)) "
+ "\"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; then "
+ "D=\"$HOME/.copilot/session-state/$d\"; break; fi; done; "
+ "if [ -n \"$D\" ]; then "
+ "tail -c 65536 \"$D/events.jsonl\" 2>/dev/null | grep -aE "
+ "'\"type\":\"(session\\.start|session\\.resume|user\\.message|permission\\.requested"
+ "|permission\\.completed|tool\\.execution_complete)\"|\"toolName\":\"ask_user\"'"
+ " | cut -c1-300; echo '\(marker) read'; else echo '\(marker) booting'; fi; "
+ "else echo '\(marker) absent'; fi"
return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script))
}

static func remoteDecisionPending(
succeeded: Bool, output: String, expectingOpening: Bool = false
) -> Bool {
let marker = ZmxSessionLauncher.remoteProbeMarker
let lines = output.split(whereSeparator: \.isNewline)
guard succeeded, let verdict = lines.last(where: { $0.hasPrefix(marker) }) else { return true }
switch verdict.dropFirst(marker.count).trimmingCharacters(in: .whitespaces) {
case "absent": return false
case "read": return holdsADecision(lines, expectingOpening: expectingOpening)
default: return true
}
}

/// What this node's Copilot session is doing, or `nil` when nothing says.
///
/// Local only, deliberately. The remote twin of this would have to carry a JSON record
Expand Down Expand Up @@ -312,7 +407,8 @@ public enum CopilotSessionLog {
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
let find =
"F=''; for d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do "
+ "if grep -qx 'name: \(name)' \"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; "
+ "if \(CLISessionBackendKind.copilotNameMatcher(for: name)) "
+ "\"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; "
+ "then F=\"$HOME/.copilot/session-state/$d/events.jsonl\"; break; fi; done"
let keep =
"grep -aE '\"type\":\"(user\\.message|assistant\\.message|tool\\.execution_start"
Expand Down Expand Up @@ -433,7 +529,7 @@ public enum CopilotSessionLog {
let logged = DialLog.fragment(session: name, dial: "bank", event: "copilot-id")
return "{ [ -s \(idFile) ] || { gc_sid=''; "
+ "for gc_d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do "
+ "if grep -qx 'name: \(name)' "
+ "if \(CLISessionBackendKind.copilotNameMatcher(for: name)) "
+ "\"$HOME/.copilot/session-state/$gc_d/workspace.yaml\" 2>/dev/null; then "
+ "gc_sid=\"$gc_d\"; break; fi; done; "
+ "if [ -n \"$gc_sid\" ]; then mkdir -p \(sessions); "
Expand All @@ -450,7 +546,8 @@ public enum CopilotSessionLog {

let findDir =
"D=''; for d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do "
+ "if grep -qx 'name: \(name)' \"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; then "
+ "if \(CLISessionBackendKind.copilotNameMatcher(for: name)) "
+ "\"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; then "
+ "D=\"$HOME/.copilot/session-state/$d\"; break; fi; done"

// The keep-list carries `lifecycleBoundaries` as well as the mapped events, so
Expand Down
2 changes: 1 addition & 1 deletion GraphcodeKit/Sources/Sessions/SessionTransplant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ public enum SessionTransplant {
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
return "S=$(cat \(idFile) 2>/dev/null); if [ -z \"$S\" ]; then "
+ "for d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do "
+ "if grep -qx 'name: \(name)' "
+ "if \(CLISessionBackendKind.copilotNameMatcher(for: name)) "
+ "\"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; "
+ "then S=\"$d\"; break; fi; done; fi; "
+ "[ -n \"$S\" ] && [ -d \"$HOME/.copilot/session-state/$S\" ] || exit 0; "
Expand Down
29 changes: 29 additions & 0 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3085,6 +3085,34 @@ public enum ZmxSessionLauncher {
}
}

/// Holds a `/rename` until typing it can't collide with anything. The first pass types
/// outside `GraphStore`'s per-session chain, and its text would land in the name; a
/// question Copilot is waiting on would take the keystrokes as its answer
/// (`CopilotSessionLog.awaitingDecision`). `false` once the wait runs out: a name that
/// stays stale costs far less than either collision.
static func awaitRenameWindow(
of node: LoopNode, projectPath: String?, polls: Int = renameWindowPolls,
interval: Duration = renameWindowInterval
) async -> Bool {
for poll in 0..<polls {
if poll > 0 { try? await Task.sleep(for: interval) }
if await !FirstPassTickets.shared.isClaimed(node.id),
await !CopilotSessionLog.awaitingDecision(of: node, projectPath: projectPath)
{
return true
}
}
DialLog.record(
session: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName,
dial: "rename", event: "window-closed")
return false
}

/// Eighteen reads over three minutes: longer than a whole first pass or a slow opening,
/// and few enough that a remote dialog nobody answers costs little.
static let renameWindowPolls = 18
static let renameWindowInterval: Duration = .seconds(10)

/// The verification window per attempt is generous next to the instant write Copilot
/// actually does, and the attempt count is small: three swallowed sends in a row means
/// something structural, not something a fourth send fixes.
Expand Down Expand Up @@ -3144,6 +3172,7 @@ public enum ZmxSessionLauncher {
private var claimed: Set<UUID> = []
func claim(_ id: UUID) -> Bool { claimed.insert(id).inserted }
func release(_ id: UUID) { claimed.remove(id) }
func isClaimed(_ id: UUID) -> Bool { claimed.contains(id) }
}

/// How long a resumed session has to still be there before its launch counts as taken.
Expand Down
Loading
Loading