Skip to content
Open
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
16 changes: 15 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
/// `nil` until the summary producer is switched on in Settings, which is where it stays
/// for anyone who never turns the experiment on.
public var summary: LoopSummary?
/// What this node's session last said it was called — `/rename` inside Claude Code.
///
/// Recorded so the rename fires **once per change** rather than once per poll. Without
/// it, the 15s tick would re-apply the session's name forever, and renaming a loop from
/// the sidebar would silently undo itself a few seconds later. Persisted for the same
/// reason: a daemon restart must not make an already-applied rename look new again.
///
/// It is the last *observed* title, not the node's title — the two differ the moment a
/// human renames the card afterwards, and that difference is what keeps the card's name
/// theirs.
public var sessionTitle: String?
/// The same run, drawn — a Mermaid flowchart or a table, composed once per finished pass
/// and rendered natively by the rail (`SummaryBoard`).
///
Expand Down Expand Up @@ -213,6 +224,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
usage: UsageSample? = nil,
activity: String? = nil,
summary: LoopSummary? = nil,
sessionTitle: String? = nil,
board: SummaryBoard? = nil,
presence: PresenceReading? = nil,
metricHistory: [MetricSample] = [],
Expand Down Expand Up @@ -242,6 +254,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
self.usage = usage
self.activity = activity
self.summary = summary
self.sessionTitle = sessionTitle
self.board = board
self.presence = presence
self.metricHistory = metricHistory
Expand Down Expand Up @@ -490,7 +503,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy
case lastArtifactoryRead, artifactoryWatch
case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly
case summary, board, heartbeatIntervalSeconds, stallReason
case summary, sessionTitle, board, heartbeatIntervalSeconds, stallReason
case createdFromTemplateID, templateFollow, sessionRestarts
}

Expand Down Expand Up @@ -525,6 +538,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
// account of a run, and a resolved loop's is the thing worth reading after the fact.
// It is bounded by construction, so a graph file cannot grow on it.
summary = try container.decodeIfPresent(LoopSummary.self, forKey: .summary)
sessionTitle = try container.decodeIfPresent(String.self, forKey: .sessionTitle)
// Survives a reload for the reason above, and one more: it cost a model call, and a
// picture that has to be paid for again on every relaunch is a picture nobody keeps.
board = try container.decodeIfPresent(SummaryBoard.self, forKey: .board)
Expand Down
40 changes: 39 additions & 1 deletion GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public actor GraphStore {
/// What a working session has narrated, folded into `LoopNode.summary`. `nil` when
/// nothing produces beats — no reader wired, or the human has left the producer off.
private let onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)?
private let onReadSessionTitle: (@Sendable (LoopNode, String?) async -> String?)?
private let onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)?
/// Whether a local loop's session is alive and not a husk — what decides if a pane
/// closing may resolve the loop (`sessionPermitsResolution`).
Expand Down Expand Up @@ -217,6 +218,7 @@ public actor GraphStore {
onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = nil,
onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil,
onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = nil,
onReadSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? = nil,
onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = nil,
onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = nil,
onSpawnIntoProject: (@Sendable (String, NodeDraft) -> Void)? = nil,
Expand Down Expand Up @@ -249,6 +251,7 @@ public actor GraphStore {
self.onReadUsage = onReadUsage
self.onReadActivity = onReadActivity
self.onReadSummary = onReadSummary
self.onReadSessionTitle = onReadSessionTitle
self.onReadPresence = onReadPresence
self.onSessionAlive = onSessionAlive
self.onSpawnIntoProject = onSpawnIntoProject
Expand Down Expand Up @@ -1004,6 +1007,34 @@ public actor GraphStore {
return changed
}

/// Applies a rename a human typed inside a session to the loop it belongs to (#261).
///
/// A title is the one thing about a loop that is expected to be wrong: it is written
/// before the work exists, and what the loop turns out to be is known only once the
/// session is running. `/rename` is a human saying exactly that, and until this existed
/// the card went on showing the name they had just replaced.
///
/// **Fires on change, not on sight.** `node.sessionTitle` records what the session was
/// last *observed* to call itself, which is not the same as what the node is called: the
/// two part company the moment someone renames the card afterwards, and that gap is what
/// keeps the card's name theirs instead of having it overwritten 15 seconds later. A
/// session nobody has renamed reports nothing and nothing here runs.
private func refreshSessionTitles() async -> Bool {
guard let onReadSessionTitle else { return false }
let path = graph.project.path
var renamed = false
for node in graph.nodes where !node.isResolved {
guard let observed = await onReadSessionTitle(node, path), observed != node.sessionTitle
else { continue }
graph.nodes[id: node.id]?.sessionTitle = observed
// Through the same door a human's rename comes through, so a blank title is refused
// and everything a rename deliberately leaves alone stays left alone.
renameNode(node.id, to: observed)
renamed = true
}
return renamed
}

/// Draws the passes that have ended since the last tick — the only reading here that
/// costs money, and the only one that is allowed to skip work it could do.
///
Expand Down Expand Up @@ -1165,14 +1196,21 @@ public actor GraphStore {
var changed = await refreshPresence()
if await refreshActivity() { changed = true }
if await refreshSummary() { changed = true }
// Kept apart from `changed` because it alone has to reach disk. Every other reading on
// this tick is a field restored from the session next time anyone asks; a rename is
// the node's own title, and `sessionTitle` is the record that stops the following tick
// applying it a second time. Told to clients only, both would be gone at the next
// daemon restart — and the rename would then land again as if it were new.
let renamed = await refreshSessionTitles()
if renamed { changed = true }
// After the summary and never beside it: a board is drawn *from* the merged summary,
// so a pass that ended on this tick has to be counted before it can be drawn.
if await refreshBoards() { changed = true }
// The poll that just learned a target went idle is the natural moment to hand it
// what was waiting on exactly that.
await drainPendingFollowUps()
guard changed else { return }
notifyClients()
if renamed { broadcast() } else { notifyClients() }
}

// MARK: - Renaming
Expand Down
5 changes: 5 additions & 0 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public actor ProjectRegistry {
private let readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)?
private let readActivity: (@Sendable (LoopNode, String?) async -> String?)?
private let readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)?
private let readSessionTitle: (@Sendable (LoopNode, String?) async -> String?)?
private let readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)?
private let sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)?
private let composeBoard:
Expand Down Expand Up @@ -74,6 +75,8 @@ public actor ProjectRegistry {
CLISessionBackend.readActivity,
readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? =
CLISessionBackend.readSummary,
readSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? =
CLISessionBackend.readSessionTitle,
readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? =
CLISessionBackend.readPresence,
sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = CLISessionBackend.sessionAlive,
Expand All @@ -92,6 +95,7 @@ public actor ProjectRegistry {
self.readUsage = readUsage
self.readActivity = readActivity
self.readSummary = readSummary
self.readSessionTitle = readSessionTitle
self.readPresence = readPresence
self.sessionAlive = sessionAlive
self.composeBoard = composeBoard
Expand Down Expand Up @@ -582,6 +586,7 @@ public actor ProjectRegistry {
onReadUsage: readUsage,
onReadActivity: readActivity,
onReadSummary: readSummary,
onReadSessionTitle: readSessionTitle,
onReadPresence: readPresence,
onSessionAlive: sessionAlive,
onSpawnIntoProject: spawnIntoProject,
Expand Down
28 changes: 27 additions & 1 deletion GraphcodeKit/Sources/Sessions/CLISessionBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ public struct CLISessionBackend: Sendable {
/// `LoopNode.summary` by `GraphStore`, never written straight onto the node — see
/// `LoopSummary.merge` for why the store has the last word.
public var summary: @Sendable (LoopNode, String?) async -> SummaryReading?
/// What the session says it is *called*, or `nil` when the backend has no such notion.
/// A human renaming a session from inside it is the one thing that can tell graphcode a
/// loop's title is wrong, and only Claude Code writes it down — see
/// `ClaudeSessionLog.sessionTitle`.
public var sessionTitle: @Sendable (LoopNode, String?) async -> String?

public init(
kind: CLISessionBackendKind,
Expand All @@ -66,7 +71,8 @@ public struct CLISessionBackend: Sendable {
presence: @escaping @Sendable (LoopNode, String?) async -> PresenceReading,
usage: @escaping @Sendable (LoopNode, String?) async -> UsageSample?,
activity: @escaping @Sendable (LoopNode, String?) async -> String? = { _, _ in nil },
summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil }
summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil },
sessionTitle: @escaping @Sendable (LoopNode, String?) async -> String? = { _, _ in nil }
) {
self.kind = kind
self.launch = launch
Expand All @@ -77,6 +83,7 @@ public struct CLISessionBackend: Sendable {
self.usage = usage
self.activity = activity
self.summary = summary
self.sessionTitle = sessionTitle
}
}

Expand Down Expand Up @@ -179,6 +186,19 @@ extension CLISessionBackend {
guard let reading else { return nil }
return await SummaryModelWriter.applied(
to: reading, node: node, projectPath: projectPath, settings: settings)
},
// Ungated by `summarisesLoops`, unlike the rail above: that setting is about how
// closely graphcode watches a session, and this is a human having already said what
// the loop is called. Suppressing it would be reading the answer and discarding it.
sessionTitle: { node, projectPath in
switch kind {
case .claudeCode:
return await ClaudeSessionLog.sessionTitle(of: node, projectPath: projectPath)
case .copilotCLI, .codex, .openCode:
// None of the three has a rename that leaves a record behind. `nil` is the
// honest answer; a guess here would rename a loop from a string the CLI chose.
return nil
}
}
)
}
Expand Down Expand Up @@ -271,6 +291,12 @@ extension CLISessionBackend {
await backend(for: node).summary(node, path)
}

/// The session-title hook `GraphStore` is wired with.
public static let readSessionTitle: @Sendable (LoopNode, String?) async -> String? = {
node, path in
await backend(for: node).sessionTitle(node, path)
}

/// The presence-reading hook `GraphStore` is wired with. The last missing link in a
/// chain that was otherwise complete: `presence` was implemented on every adapter and
/// called by nothing, so every surface had only `LoopState` to go on and a loop that
Expand Down
47 changes: 47 additions & 0 deletions GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,53 @@ public enum ClaudeSessionLog {
let reading = reading(inTranscriptAt: transcript, metricSamples: node.metricHistory)
return reading.isEmpty ? nil : reading
}

/// The name a human gave this session with `/rename`, or `nil` when nobody has.
///
/// Claude Code writes it as a standalone record —
/// `{"type":"custom-title","customTitle":"…"}` — and re-emits it at every checkpoint
/// rather than once at the rename, so a tail read always carries the current one and the
/// last one wins.
///
/// **Only `custom-title`.** Every session also carries an `agent-name` record, and it is
/// not the same thing: it holds a name the CLI assigned itself (`angleReuse2`) and is
/// present in sessions nobody has renamed. Reading it would rename half a graph to
/// strings no human ever typed.
static func customTitle(inLines lines: [Data]) -> String? {
var latest: String?
for line in lines {
guard let object = try? JSONSerialization.jsonObject(with: line),
let record = object as? [String: Any],
record["type"] as? String == "custom-title",
let title = record["customTitle"] as? String
else { continue }
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
latest = trimmed
}
return latest
}

/// What this node's session calls itself, or `nil` when nothing does.
///
/// **Local only, deliberately.** A remote loop's transcript is on the other machine, and
/// every reading of it costs an ssh probe per node per poll. The summary rail pays that
/// because a rail changes every few seconds; a title changes when a human types six
/// characters, perhaps once in a loop's life. Reported as "nothing said" rather than
/// bought at that price — the same answer this gives a backend that writes no transcript.
/// Gated on the transcript having moved, like the rail is, and for a sharper reason: the
/// summary rail is off by default, so nothing was reading transcripts on a default
/// install at all. Ungated this would have added a 512KB tail read per loop every
/// fifteen seconds to every install there is, to re-read a name that changes when a
/// human types `/rename`. A quiet loop now costs one `stat`.
public static func sessionTitle(of node: LoopNode, projectPath: String? = nil) async -> String? {
guard projectPath.flatMap(RemoteProjectLocation.parse(projectPath:)) == nil,
let sessionID = SessionIDStore.load(forNodeID: node.id),
let transcript = transcript(forSessionID: sessionID),
await TranscriptFreshness.titles.hasChanged(transcript, forNode: node.id)
else { return nil }
return customTitle(inLines: SummaryBeatBuilder.tailLines(of: transcript))
}
}

/// How the one number the rail carries is written.
Expand Down
8 changes: 8 additions & 0 deletions GraphcodeKit/Sources/Sessions/TranscriptFreshness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import Foundation
actor TranscriptFreshness {
static let shared = TranscriptFreshness()

/// The session-title reader's own instance (`ClaudeSessionLog.sessionTitle`).
///
/// A second instance rather than a second caller of `shared`, because `hasChanged` is
/// consuming by design: it records the date it just saw, so of two readers asking about
/// the same file on the same tick the second is always told "unchanged". Sharing it
/// would have left the summary rail and the title reader taking turns to work.
static let titles = TranscriptFreshness()

private var seen: [UUID: Date] = [:]

/// True when `url` has been modified since this node last read it — and records the new
Expand Down
Loading
Loading