@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -33,19 +33,23 @@ import {
33
33
  type TurnResult,
34
34
  } from "../protocol"
35
35
  import { extractReply } from "../reply"
36
+ import { busyError, NOT_A_USER_MESSAGE, REVERT_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages } from "./fork-revert"
36
37
  import { TurnTracker } from "../turns"
37
38
  import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
38
39
  import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
39
40
  import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
40
- import { DeliveryLedger, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery } from "./deliver"
41
+ import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY } from "./deliver"
41
42
  import { pollEnrollment, startEnrollment } from "./enroll"
42
43
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
43
44
  import type { LocalHubResult } from "./localhub"
44
45
  import { launchDirectoryProblem } from "./node-roots"
45
46
  import { PresenceBook } from "./presence"
47
+ import { OpenCodeProfiles } from "./profile-apply"
46
48
  import { SessionTokenStore } from "./session-tokens"
49
+ import { SessionContexts } from "./session-context"
50
+ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
47
51
  import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
48
- import { browserSessions, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
52
+ import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
49
53
  import { renderTaskBoard, TaskBoard } from "./taskboard"
50
54
  import { watchConfig, type ConfigWatcher } from "./watch"
51
55
 
@@ -138,8 +142,10 @@ export interface SpokeHost {
138
142
  */
139
143
  activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
140
144
  /**
141
- * Recent root sessions from the local service with their workspace (service processes only): those in
142
- * the history window (`days`, at most `max`), plus `include` (always listed: open, running, ...).
145
+ * Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
146
+ * (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
147
+ * only those of the locations it loaded. Absent (a TUI's own server, which this process cannot reach): the
148
+ * process reports only the sessions it sees active.
143
149
  */
144
150
  listRecent?(options?: { days: number; max: number; include: readonly string[] }): Promise<Array<{ id: string } & SessionDetails>>
145
151
  /** Creates a session in a directory (service processes only). */
@@ -150,6 +156,21 @@ export interface SpokeHost {
150
156
  vcsDiff?(directory: string): Promise<ReadonlyArray<unknown>>
151
157
  /** Current branch of a directory. */
152
158
  vcsBranch?(directory: string): Promise<string | undefined>
159
+ /** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
160
+ localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] }>
161
+ /** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
162
+ readonly profileTool?: boolean
163
+ /**
164
+ * Forks a session before one of its messages (OpenCode's session.fork over the own server; the plugin API has
165
+ * none). Absent: this process cannot reach its server, so it does not announce "fork".
166
+ */
167
+ forkSession?(input: { sessionID: string; before: string }): Promise<{ id: string; title?: string; location?: { directory?: string } }>
168
+ /** Stages a revert from a message (session.revert.stage); with `files`, restores the files changed since. */
169
+ revertStage?(input: { sessionID: string; messageID: string; files: boolean }): Promise<unknown>
170
+ /** Undoes a staged revert (session.revert.clear). */
171
+ revertClear?(sessionID: string): Promise<unknown>
172
+ /** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
173
+ revertCommit?(sessionID: string): Promise<unknown>
153
174
  }
154
175
 
155
176
  /** A form reply for OpenCode: an answer, or a cancel. */
@@ -164,6 +185,8 @@ const RELAY_TIMEOUT_MS = 15_000
164
185
  const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from the hub; answer it in the terminal"
165
186
 
166
187
  export interface SpokeOptions {
188
+ /** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
189
+ readonly conciergeLockFile?: string
167
190
  readonly configFile: string
168
191
  readonly machineName: string
169
192
  readonly kind: ProcessKind
@@ -176,6 +199,8 @@ export interface SpokeOptions {
176
199
  readonly seedIntervalMs?: number
177
200
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
178
201
  readonly seedDebounceMs?: number
202
+ /** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
203
+ readonly listingTimeoutMs?: number
179
204
  readonly pollMs?: number
180
205
  readonly backoff?: Backoff
181
206
  /**
@@ -270,12 +295,16 @@ const FALLBACK_AGENT = "build"
270
295
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
271
296
  const ENROLL_RETRY_MS = 500
272
297
  const NOT_SHARED = "This session is not shared with voice"
298
+ const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
299
+ const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
300
+ /** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
301
+ const REVERT_EVENTS = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"])
273
302
  const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
274
303
  export const AUTO_NAME_WAIT_MS = 20_000
275
304
  /** Longest session title the hub sets (OpenCode's own titles are shorter). */
276
305
  export const MAX_TITLE_CHARS = 200
277
306
  const UNKNOWN_SESSION = "Unknown session"
278
- /** Events after which the service re-lists its sessions (other processes' sessions appear, change, go). */
307
+ /** Events after which a listing process re-lists its sessions (other processes' sessions appear, change, go). */
279
308
  const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
280
309
 
281
310
  const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
@@ -292,6 +321,10 @@ export class Spoke {
292
321
  private connection?: SpokeConnection
293
322
  private connectionKey = ""
294
323
  private readonly tracker = new SessionTracker()
324
+ /** Hub profiles applied to sessions (./profile-apply): session-scoped layers in memory, read by the context hook. */
325
+ readonly profiles: OpenCodeProfiles
326
+ /** (CON-04) Hub session contexts (sessionContext op): per-turn parts, denied tools and lockAgent, read by the context hook. */
327
+ readonly contexts: SessionContexts
295
328
  /** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
296
329
  private readonly tokens = new SessionTokenStore()
297
330
  /**
@@ -327,7 +360,18 @@ export class Spoke {
327
360
  private conversationChain: Promise<unknown> = Promise.resolve()
328
361
  private readonly abort = new AbortController()
329
362
  private snapshotTimer?: ReturnType<typeof setTimeout>
363
+ /** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
364
+ private announced = new Set<string>()
365
+ /** Sessions whose run failed before any snapshot listed them: the failure goes out again once one does. */
366
+ private unheardFailures = new Set<string>()
330
367
  private seedTimer?: ReturnType<typeof setTimeout>
368
+ /** A listing runs; `relist`: another was asked for meanwhile (runs once it ends, never alongside). */
369
+ private listing = false
370
+ private relist = false
371
+ /** Bumped per listing: a listing applies its result only while it is still the latest (a timed-out one never does). */
372
+ private listingGeneration = 0
373
+ /** A listing timed out and was logged; reset by the next listing that completes. */
374
+ private listingHung = false
331
375
  private intervals: Array<ReturnType<typeof setInterval>> = []
332
376
  private enrollment?: { state: EnrollmentState; abort: AbortController }
333
377
  private started = false
@@ -366,6 +410,13 @@ export class Spoke {
366
410
  ) {
367
411
  this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
368
412
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
413
+ this.contexts = new SessionContexts(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {})
414
+ const localNames = host.localNames?.bind(host)
415
+ this.profiles = new OpenCodeProfiles({
416
+ isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
417
+ ...(localNames ? { localNames: (id: string) => localNames(this.directoryOf(id)) } : {}),
418
+ tool: () => host.profileTool === true,
419
+ })
369
420
  }
370
421
 
371
422
  start(): void {
@@ -374,9 +425,9 @@ export class Spoke {
374
425
  if (!this.options.launch) this.watch()
375
426
  void this.listen()
376
427
  this.apply(this.config)
377
- this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
428
+ this.every(this.options.snapshotIntervalMs ?? 30_000, () => (void this.refreshLockedAgents(), this.sendSnapshot()))
378
429
  this.every(this.options.presenceCheckMs ?? 5000, () => this.checkOpen())
379
- if (this.options.kind === "service" && this.host.listRecent) {
430
+ if (this.host.listRecent) {
380
431
  void this.seed()
381
432
  this.every(this.options.seedIntervalMs ?? 60_000, () => void this.seed())
382
433
  }
@@ -502,7 +553,7 @@ export class Spoke {
502
553
  if (key === this.openKey) return
503
554
  this.openKey = key
504
555
  const unknown = [...open.keys()].filter((id) => !this.tracker.has(id))
505
- if (unknown.length && this.options.kind === "service" && this.host.listRecent) this.scheduleSeed()
556
+ if (unknown.length && this.host.listRecent) this.scheduleSeed()
506
557
  else if (unknown.length) {
507
558
  for (const id of unknown) this.tracker.ensure(id, Date.now())
508
559
  void this.fetchInfo()
@@ -825,7 +876,13 @@ export class Spoke {
825
876
  opencodeVersion: this.host.opencodeVersion,
826
877
  pluginVersion: this.options.pluginVersion,
827
878
  // Hub messages and prompts need OpenCode's synthetic messages.
828
- features: SPOKE_FEATURES.filter((f) => (f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined),
879
+ // Fork and revert need OpenCode's own server (the plugin API has neither).
880
+ features: SPOKE_FEATURES.filter(
881
+ (f) =>
882
+ ((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
883
+ (f !== "fork" || this.host.forkSession !== undefined) &&
884
+ (f !== "revert" || this.canRevert()),
885
+ ),
829
886
  harness: HARNESS_OPENCODE,
830
887
  harnessVersion: this.host.opencodeVersion,
831
888
  evidence: "native",
@@ -905,6 +962,13 @@ export class Spoke {
905
962
  private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
906
963
  const entry = this.conversations.get(sessionID)
907
964
  const session = await this.host.getSession(sessionID)
965
+ // (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
966
+ // never escapes its restricted agent, and the context hook adds no orchestrator parts).
967
+ // A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
968
+ if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
969
+ this.conversations.set(sessionID, {})
970
+ return { agent: session.agent ?? (await this.defaultAgent()) }
971
+ }
908
972
  this.rememberModel(sessionID, session.model)
909
973
  const current = session.agent
910
974
  const models = await this.voiceModelOn(sessionID, session, entry)
@@ -1023,9 +1087,10 @@ export class Spoke {
1023
1087
  return this.modelPrefsResult(req.sessionID)
1024
1088
  }
1025
1089
  case "timeline": {
1026
- // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
1090
+ // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
1091
+ // staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
1027
1092
  const first = req.cursor === undefined && req.before === undefined
1028
- const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.host.context(req.sessionID)), first)
1093
+ const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.shownMessages(req.sessionID)), first)
1029
1094
  if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
1030
1095
  const offset = req.cursor === undefined ? 0 : Number(req.cursor)
1031
1096
  if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
@@ -1069,6 +1134,134 @@ export class Spoke {
1069
1134
  return this.setConversation(req.sessionID, req.on)
1070
1135
  case "deliver":
1071
1136
  return this.deliver(req)
1137
+ case "sessionContext": {
1138
+ // Only a session this process reports (a context needs a real session).
1139
+ if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1140
+ const result = this.contexts.handle(req)
1141
+ // The hub sees the new revision (and the session's agent) in the next sessions frame.
1142
+ void this.refreshLockedAgents().finally(() => this.scheduleSnapshot())
1143
+ return result
1144
+ }
1145
+ case "profileApply":
1146
+ // Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
1147
+ if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1148
+ return this.profiles.handle(req)
1149
+ case "fork":
1150
+ return this.fork(req.sessionID, req.messageID)
1151
+ case "revertPreview": {
1152
+ const messages = await this.host.context(req.sessionID)
1153
+ const index = userIndex(messages, req.messageID)
1154
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1155
+ return revertPreview(messages, index) satisfies ResultFor["revertPreview"]
1156
+ }
1157
+ case "revert":
1158
+ return this.revert(req.sessionID, req.messageID, req.files)
1159
+ case "revertRestore":
1160
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1161
+ await this.host.revertClear!(req.sessionID)
1162
+ return this.revertDone(req.sessionID)
1163
+ case "revertCommit":
1164
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1165
+ await this.host.revertCommit!(req.sessionID)
1166
+ return this.revertDone(req.sessionID)
1167
+ }
1168
+ }
1169
+
1170
+ private canRevert(): boolean {
1171
+ return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
1172
+ }
1173
+
1174
+ /**
1175
+ * Forks the session before one of its user messages (feature "fork") and reports the new session at once (the
1176
+ * snapshot goes out before the reply). The result carries the message's text and attachments for the composer.
1177
+ */
1178
+ private async fork(sessionID: string, messageID: string): Promise<ResultFor["fork"]> {
1179
+ const fork = this.host.forkSession?.bind(this.host)
1180
+ if (!fork) throw new Error(CANNOT_FORK)
1181
+ const messages = await this.host.context(sessionID)
1182
+ const index = userIndex(messages, messageID)
1183
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1184
+ const session = await fork({ sessionID, before: messageID })
1185
+ if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
1186
+ const title = typeof session.title === "string" ? session.title : ""
1187
+ const directory = typeof session.location?.directory === "string" && session.location.directory ? session.location.directory : (this.tracker.directoryOf(sessionID) ?? this.host.directory)
1188
+ const now = Date.now()
1189
+ const source = this.tracker.snapshot(now).find((s) => s.id === sessionID)
1190
+ this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title, location: { directory } } }, now)
1191
+ this.tracker.setInfo(session.id, {
1192
+ title,
1193
+ directory,
1194
+ updated: now,
1195
+ ...(source?.project ? { project: source.project } : {}),
1196
+ ...(source?.projectName ? { projectName: source.projectName } : {}),
1197
+ })
1198
+ if (this.config.mode === "session") this.setSession(session.id, true)
1199
+ this.sendSnapshot()
1200
+ return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
1201
+ }
1202
+
1203
+ /**
1204
+ * Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
1205
+ * `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
1206
+ */
1207
+ private async revert(sessionID: string, messageID: string, files: boolean): Promise<ResultFor["revert"]> {
1208
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1209
+ const messages = await this.host.context(sessionID)
1210
+ const index = userIndex(messages, messageID)
1211
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1212
+ if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new Error(REVERT_BUSY)
1213
+ let staged: unknown
1214
+ try {
1215
+ staged = await this.host.revertStage!({ sessionID, messageID, files })
1216
+ } catch (cause) {
1217
+ if (busyError(cause)) throw new Error(REVERT_BUSY)
1218
+ throw cause
1219
+ }
1220
+ const revert = revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!
1221
+ this.timelines.invalidate(sessionID)
1222
+ // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1223
+ this.tracker.setRevert(sessionID, revert)
1224
+ this.sendSnapshot()
1225
+ return { revert, ...userContent(messages[index]) }
1226
+ }
1227
+
1228
+ /** After a restore or commit: no revert is staged; reported at once. */
1229
+ private revertDone(sessionID: string): Record<string, never> {
1230
+ this.timelines.invalidate(sessionID)
1231
+ this.tracker.setRevert(sessionID, undefined)
1232
+ this.sendSnapshot()
1233
+ return {}
1234
+ }
1235
+
1236
+ /**
1237
+ * The session's messages a timeline shows: all of them, or with a staged revert those before its message. The
1238
+ * revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
1239
+ */
1240
+ private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
1241
+ const [messages, session] = await Promise.all([
1242
+ this.host.context(sessionID),
1243
+ this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
1244
+ ])
1245
+ if (!this.canRevert()) return messages
1246
+ const raw = session ? (session as { revert?: unknown }).revert : undefined
1247
+ const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
1248
+ if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1249
+ return visibleMessages(messages, revert?.messageID)
1250
+ }
1251
+
1252
+ /** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
1253
+ private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1254
+ if (!this.tracker.has(sessionID)) return
1255
+ if (event.type !== "session.revert.staged") {
1256
+ if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
1257
+ return
1258
+ }
1259
+ try {
1260
+ const raw = (event.data as Record<string, unknown>).revert
1261
+ const revert = revertSummary(raw, await this.host.context(sessionID))
1262
+ if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1263
+ } catch (cause) {
1264
+ console.error("[hub] could not read a staged revert", cause)
1072
1265
  }
1073
1266
  }
1074
1267
 
@@ -1081,11 +1274,11 @@ export class Spoke {
1081
1274
  if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1082
1275
  return this.deliveries.run(req.messageID, req.wake, async () => {
1083
1276
  const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
1084
- const plan = planDelivery(req, {
1085
- busy: this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID),
1086
- formOpen: this.tracker.formOpenIn(req.sessionID),
1087
- subagent,
1088
- })
1277
+ const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
1278
+ // A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
1279
+ // pending and sends it again after the turn (the turn's end sends a sessions frame). Nothing is admitted.
1280
+ if (busy && deferredWhileBusy(req, subagent)) throw new Error(QUIET_WHILE_BUSY)
1281
+ const plan = planDelivery(req, { busy, formOpen: this.tracker.formOpenIn(req.sessionID), subagent })
1089
1282
  const id = harnessMessageID(req.messageID)
1090
1283
  // Marked before sending: OpenCode may report it delivered before the call returns.
1091
1284
  this.origins.hub(id)
@@ -1488,6 +1681,7 @@ export class Spoke {
1488
1681
  if (event.type === "session.deleted" && typeof sessionID === "string") {
1489
1682
  this.tokens.delete(sessionID)
1490
1683
  this.actTokens.delete(sessionID)
1684
+ this.profiles.forget(sessionID)
1491
1685
  }
1492
1686
  if (event.type === "form.replied" || event.type === "form.cancelled") {
1493
1687
  const formID = (event.data as Record<string, unknown> | undefined)?.id
@@ -1502,7 +1696,9 @@ export class Spoke {
1502
1696
  if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
1503
1697
  }
1504
1698
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1505
- if (this.options.kind === "service" && this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
1699
+ if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
1700
+ if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
1701
+ if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
1506
1702
  this.scheduleSeed()
1507
1703
  }
1508
1704
  void this.fetchInfo()
@@ -1510,7 +1706,11 @@ export class Spoke {
1510
1706
  // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1511
1707
  // get their session from the job.
1512
1708
  const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1513
- if (compact && shouldForward(this.config, this.tracker, compact)) this.connection.send({ t: "event", event: compact })
1709
+ if (compact && shouldForward(this.config, this.tracker, compact)) {
1710
+ this.announce(compact)
1711
+ this.connection.send({ t: "event", event: compact })
1712
+ if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1713
+ }
1514
1714
  }
1515
1715
  } catch (cause) {
1516
1716
  console.error("[hub] event handling failed", cause)
@@ -1599,13 +1799,50 @@ export class Spoke {
1599
1799
  }
1600
1800
 
1601
1801
  private async seed(): Promise<void> {
1802
+ if (this.listing) {
1803
+ this.relist = true
1804
+ return
1805
+ }
1806
+ this.listing = true
1807
+ try {
1808
+ do {
1809
+ this.relist = false
1810
+ await this.listOnce()
1811
+ } while (this.relist && this.started)
1812
+ } finally {
1813
+ this.listing = false
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * One listing, bounded by listingTimeoutMs: a server that accepts and never answers must not stop later listings
1819
+ * (seed() runs one at a time). A timed-out listing is abandoned (logged once until a listing completes again);
1820
+ * if it settles later its result is dropped, so it never overwrites a newer listing.
1821
+ */
1822
+ private async listOnce(): Promise<void> {
1823
+ const generation = ++this.listingGeneration
1824
+ const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
1825
+ let timer: ReturnType<typeof setTimeout> | undefined
1826
+ const timedOut = Symbol("timeout")
1602
1827
  try {
1603
1828
  const keep = this.keep()
1604
1829
  const { days, max } = this.config.list
1605
- const sessions = (await this.host.listRecent?.({ days, max, include: [...keep] })) ?? []
1830
+ const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep] }))
1831
+ listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
1832
+ const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
1833
+ if (result === timedOut) {
1834
+ if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
1835
+ this.listingHung = true
1836
+ return
1837
+ }
1838
+ this.listingHung = false
1839
+ if (generation !== this.listingGeneration) return
1840
+ const sessions = result ?? []
1606
1841
  if (this.tracker.applyListing(sessions, Date.now(), keep)) this.scheduleSnapshot()
1607
1842
  } catch (cause) {
1608
1843
  console.error("[hub] could not list recent sessions", errorText(cause))
1844
+ } finally {
1845
+ clearTimeout(timer)
1609
1846
  }
1610
1847
  }
1611
1848
 
@@ -1625,9 +1862,68 @@ export class Spoke {
1625
1862
  }, this.options.snapshotDebounceMs ?? 500)
1626
1863
  }
1627
1864
 
1865
+ /**
1866
+ * (CON-04) The agent a locked (Concierge) session runs on, as the context hook or OpenCode reports it: a change
1867
+ * goes to the hub in the next sessions frame (the hub suspends a Concierge on another agent).
1868
+ */
1869
+ noteAgent(sessionID: string, agent: string | undefined): void {
1870
+ if (this.contexts.seenAgent(sessionID, agent)) this.scheduleSnapshot()
1871
+ }
1872
+
1873
+ /** Reads the current agent of every locked session this process reports (OpenCode's session record). */
1874
+ private async refreshLockedAgents(): Promise<void> {
1875
+ for (const sessionID of this.contexts.lockedSessions()) {
1876
+ if (!this.tracker.has(sessionID)) continue
1877
+ try {
1878
+ this.noteAgent(sessionID, (await this.host.getSession(sessionID)).agent)
1879
+ } catch {
1880
+ // Gone or unreachable: the next refresh tries again.
1881
+ }
1882
+ }
1883
+ }
1884
+
1628
1885
  private sendSnapshot(): void {
1886
+ if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
1887
+ this.snapshotTimer = undefined
1629
1888
  const tui = this.presenceBook.open(Date.now())
1630
- this.connection?.send({ t: "sessions", sessions: browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }) })
1889
+ const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }).map((s) => ({ ...s, ...this.contexts.report(s.id) }))
1890
+ this.connection?.send({ t: "sessions", sessions })
1891
+ this.announced = new Set(sessions.map((s) => s.id))
1892
+ this.resendUnheardFailures()
1893
+ }
1894
+
1895
+ /**
1896
+ * A run that failed while its session's details were still being fetched went out before any snapshot listed the
1897
+ * session, so the hub dropped it: once a snapshot lists the session, its failure goes out again (unless a run
1898
+ * started since).
1899
+ */
1900
+ private resendUnheardFailures(): void {
1901
+ for (const id of [...this.unheardFailures]) {
1902
+ const error = this.tracker.errorOf(id)
1903
+ if (!error || !this.tracker.has(id)) {
1904
+ this.unheardFailures.delete(id)
1905
+ continue
1906
+ }
1907
+ if (!this.announced.has(id) || this.connection?.state !== "connected") continue
1908
+ this.unheardFailures.delete(id)
1909
+ const event = { type: "session.execution.failed", data: { sessionID: id, error } }
1910
+ if (shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * Sends the snapshot now when an event is about a session the hub was not told about yet: the hub drops a run's
1916
+ * events (a failure, a permission) for sessions it does not know, and a run can fail within milliseconds of
1917
+ * session.created (a model that does not exist), well before the debounced snapshot. Creating, renaming or
1918
+ * deleting a session alone waits for the debounce (a burst of subagents sends one snapshot).
1919
+ */
1920
+ private announce(event: { type: string; data: Record<string, unknown> }): void {
1921
+ if (event.type === "session.created" || event.type === "session.renamed" || event.type === "session.deleted") return
1922
+ const sessionID = eventSessionID(event)
1923
+ if (!sessionID || this.announced.has(sessionID) || !this.tracker.hasInfo(sessionID)) return
1924
+ this.sendSnapshot()
1925
+ // Once per session even when the snapshot's limits leave it out.
1926
+ this.announced.add(sessionID)
1631
1927
  }
1632
1928
 
1633
1929
  private every(ms: number, run: () => void): void {
@@ -1637,6 +1933,9 @@ export class Spoke {
1637
1933
  }
1638
1934
  }
1639
1935
 
1936
+ /** Longest a session listing may take (SpokeOptions.listingTimeoutMs). */
1937
+ const LISTING_TIMEOUT_MS = 60_000
1938
+
1640
1939
  /** Directories whose shells are listed when seeding. */
1641
1940
  const SEED_DIRECTORIES = 20
1642
1941
 
@@ -1,4 +1,4 @@
1
- import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
1
+ import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, summarizeToolInput, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
2
2
  import { PROMPT_SOURCE } from "./conversation"
3
3
 
4
4
  /** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
@@ -32,6 +32,8 @@ export interface TimelineItem {
32
32
  readonly callID?: string
33
33
  /** Subagent (subagent/task) tool rows: the child session it runs in. */
34
34
  readonly childSessionID?: string
35
+ /** (CON-09b) Tool rows: a short summary of the input (summarizeToolInput). */
36
+ readonly summary?: string
35
37
  /** Subagent tool rows: the task's short description. */
36
38
  readonly title?: string
37
39
  /** Assistant text rows: which text part of the message this is (0, 1, …), as in live text deltas. */
@@ -100,10 +102,13 @@ export function timelineItems(sessionID: string, messages: readonly unknown[]):
100
102
  const status = part.state?.status
101
103
  const name = String(part.name ?? "tool")
102
104
  const spawn = SUBAGENT_TOOLS.has(name) ? subagentOf(part.state) : {}
105
+ // (CON-09b) The same short summary live activity shows (the command, the path, the hub verb).
106
+ const summary = summarizeToolInput(part.state?.input)
103
107
  out.push({
104
108
  ...base,
105
109
  kind: "tool",
106
110
  name,
111
+ ...(summary ? { summary } : {}),
107
112
  state: status === "error" ? "failed" : status === "completed" ? "done" : "running",
108
113
  ...(typeof part.id === "string" && part.id ? { callID: part.id } : {}),
109
114
  ...spawn,
@@ -1,7 +1,7 @@
1
1
  // Sessions this OpenCode process knows about, built from the events it sees, plus which of them
2
2
  // are exposed to the central server (by mode). Pure: time is passed in.
3
3
  import type { Mode } from "../config"
4
- import { compactActivity, compactRunError, FORWARDED_EVENTS, type CompactRunError, type CompactSessionStatus, type JobInfo, type SessionInfo, type TerminalInfo } from "../protocol"
4
+ import { compactActivity, compactRunError, FORWARDED_EVENTS, type CompactRunError, type CompactSessionStatus, type JobInfo, type SessionInfo, type SessionRevertInfo, type TerminalInfo } from "../protocol"
5
5
 
6
6
  /** A session is "owned" by this process while it viewed or ran it within this window. */
7
7
  export const OWNED_WINDOW_MS = 30 * 60 * 1000
@@ -35,6 +35,8 @@ interface Entry {
35
35
  projectName?: string
36
36
  /** Came from the service's listing of recent sessions (dropped when it leaves the listing). */
37
37
  listed?: boolean
38
+ /** A staged revert (feature "revert"). */
39
+ revert?: SessionRevertInfo
38
40
  }
39
41
 
40
42
  const ACTIVITY = new Set([
@@ -183,6 +185,20 @@ export class SessionTracker {
183
185
  return changed
184
186
  }
185
187
 
188
+ /** The session's staged revert, if any. */
189
+ revertOf(id: string): SessionRevertInfo | undefined {
190
+ return this.entries.get(id)?.revert
191
+ }
192
+
193
+ /** Sets (or, undefined, clears) a known session's staged revert. Returns true when it changed. */
194
+ setRevert(id: string, revert: SessionRevertInfo | undefined): boolean {
195
+ const entry = this.entries.get(id)
196
+ if (!entry || JSON.stringify(entry.revert ?? null) === JSON.stringify(revert ?? null)) return false
197
+ if (revert) entry.revert = revert
198
+ else delete entry.revert
199
+ return true
200
+ }
201
+
186
202
  /** Fills in details fetched for a session that was first seen through an activity event. */
187
203
  setInfo(id: string, details: SessionDetails): boolean {
188
204
  const entry = this.entries.get(id)
@@ -331,6 +347,11 @@ export class SessionTracker {
331
347
  .sort((a, b) => this.lineage(b).length - this.lineage(a).length)
332
348
  }
333
349
 
350
+ /** The session is known with its details, so a snapshot lists it. */
351
+ hasInfo(id: string): boolean {
352
+ return this.entries.get(id)?.info === true
353
+ }
354
+
334
355
  needsInfo(): string[] {
335
356
  return [...this.entries.values()].filter((e) => !e.info).map((e) => e.id)
336
357
  }
@@ -383,6 +404,7 @@ export class SessionTracker {
383
404
  owned: e.lastActivity !== undefined && now - e.lastActivity <= OWNED_WINDOW_MS,
384
405
  ...(e.project ? { project: e.project } : {}),
385
406
  ...(e.projectName ? { projectName: e.projectName } : {}),
407
+ ...(e.revert ? { revert: e.revert } : {}),
386
408
  }))
387
409
  }
388
410