@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. 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,18 +142,36 @@ 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
- listRecent?(options?: { days: number; max: number; include: readonly string[] }): Promise<Array<{ id: string } & SessionDetails>>
150
+ /** `signal` aborts when the spoke abandons the listing because it timed out (listingTimeoutMs): stop asking the server. */
151
+ listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
145
152
  /** Creates a session in a directory (service processes only). */
146
153
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
147
154
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
148
155
  mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
149
- /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
150
- vcsDiff?(directory: string): Promise<ReadonlyArray<unknown>>
156
+ /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list); undefined when nothing can read that directory. */
157
+ vcsDiff?(directory: string): Promise<ReadonlyArray<unknown> | undefined>
151
158
  /** Current branch of a directory. */
152
159
  vcsBranch?(directory: string): Promise<string | undefined>
160
+ /** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
161
+ localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] }>
162
+ /** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
163
+ readonly profileTool?: boolean
164
+ /**
165
+ * Forks a session before one of its messages (OpenCode's session.fork over the own server; the plugin API has
166
+ * none). Absent: this process cannot reach its server, so it does not announce "fork".
167
+ */
168
+ forkSession?(input: { sessionID: string; before: string }): Promise<{ id: string; title?: string; location?: { directory?: string } }>
169
+ /** Stages a revert from a message (session.revert.stage); with `files`, restores the files changed since. */
170
+ revertStage?(input: { sessionID: string; messageID: string; files: boolean }): Promise<unknown>
171
+ /** Undoes a staged revert (session.revert.clear). */
172
+ revertClear?(sessionID: string): Promise<unknown>
173
+ /** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
174
+ revertCommit?(sessionID: string): Promise<unknown>
153
175
  }
154
176
 
155
177
  /** A form reply for OpenCode: an answer, or a cancel. */
@@ -164,6 +186,10 @@ const RELAY_TIMEOUT_MS = 15_000
164
186
  const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from the hub; answer it in the terminal"
165
187
 
166
188
  export interface SpokeOptions {
189
+ /** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
190
+ readonly conciergeLockFile?: string
191
+ /** Other Concierge lock files read, never written (the other default state dir places; FX-08, fail closed). */
192
+ readonly conciergeLockReadFiles?: readonly string[]
167
193
  readonly configFile: string
168
194
  readonly machineName: string
169
195
  readonly kind: ProcessKind
@@ -176,6 +202,8 @@ export interface SpokeOptions {
176
202
  readonly seedIntervalMs?: number
177
203
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
178
204
  readonly seedDebounceMs?: number
205
+ /** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
206
+ readonly listingTimeoutMs?: number
179
207
  readonly pollMs?: number
180
208
  readonly backoff?: Backoff
181
209
  /**
@@ -270,12 +298,16 @@ const FALLBACK_AGENT = "build"
270
298
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
271
299
  const ENROLL_RETRY_MS = 500
272
300
  const NOT_SHARED = "This session is not shared with voice"
301
+ const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
302
+ const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
303
+ /** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
304
+ const REVERT_EVENTS = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"])
273
305
  const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
274
306
  export const AUTO_NAME_WAIT_MS = 20_000
275
307
  /** Longest session title the hub sets (OpenCode's own titles are shorter). */
276
308
  export const MAX_TITLE_CHARS = 200
277
309
  const UNKNOWN_SESSION = "Unknown session"
278
- /** Events after which the service re-lists its sessions (other processes' sessions appear, change, go). */
310
+ /** Events after which a listing process re-lists its sessions (other processes' sessions appear, change, go). */
279
311
  const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
280
312
 
281
313
  const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
@@ -292,6 +324,10 @@ export class Spoke {
292
324
  private connection?: SpokeConnection
293
325
  private connectionKey = ""
294
326
  private readonly tracker = new SessionTracker()
327
+ /** Hub profiles applied to sessions (./profile-apply): session-scoped layers in memory, read by the context hook. */
328
+ readonly profiles: OpenCodeProfiles
329
+ /** (CON-04) Hub session contexts (sessionContext op): per-turn parts, denied tools and lockAgent, read by the context hook. */
330
+ readonly contexts: SessionContexts
295
331
  /** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
296
332
  private readonly tokens = new SessionTokenStore()
297
333
  /**
@@ -327,7 +363,18 @@ export class Spoke {
327
363
  private conversationChain: Promise<unknown> = Promise.resolve()
328
364
  private readonly abort = new AbortController()
329
365
  private snapshotTimer?: ReturnType<typeof setTimeout>
366
+ /** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
367
+ private announced = new Set<string>()
368
+ /** Sessions whose run failed before any snapshot listed them: the failure goes out again once one does. */
369
+ private unheardFailures = new Set<string>()
330
370
  private seedTimer?: ReturnType<typeof setTimeout>
371
+ /** A listing runs; `relist`: another was asked for meanwhile (runs once it ends, never alongside). */
372
+ private listing = false
373
+ private relist = false
374
+ /** Bumped per listing: a listing applies its result only while it is still the latest (a timed-out one never does). */
375
+ private listingGeneration = 0
376
+ /** A listing timed out and was logged; reset by the next listing that completes. */
377
+ private listingHung = false
331
378
  private intervals: Array<ReturnType<typeof setInterval>> = []
332
379
  private enrollment?: { state: EnrollmentState; abort: AbortController }
333
380
  private started = false
@@ -366,6 +413,16 @@ export class Spoke {
366
413
  ) {
367
414
  this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
368
415
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
416
+ this.contexts = new SessionContexts({
417
+ ...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
418
+ ...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
419
+ })
420
+ const localNames = host.localNames?.bind(host)
421
+ this.profiles = new OpenCodeProfiles({
422
+ isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
423
+ ...(localNames ? { localNames: (id: string) => localNames(this.directoryOf(id)) } : {}),
424
+ tool: () => host.profileTool === true,
425
+ })
369
426
  }
370
427
 
371
428
  start(): void {
@@ -374,9 +431,9 @@ export class Spoke {
374
431
  if (!this.options.launch) this.watch()
375
432
  void this.listen()
376
433
  this.apply(this.config)
377
- this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
434
+ this.every(this.options.snapshotIntervalMs ?? 30_000, () => (void this.refreshLockedAgents(), this.sendSnapshot()))
378
435
  this.every(this.options.presenceCheckMs ?? 5000, () => this.checkOpen())
379
- if (this.options.kind === "service" && this.host.listRecent) {
436
+ if (this.host.listRecent) {
380
437
  void this.seed()
381
438
  this.every(this.options.seedIntervalMs ?? 60_000, () => void this.seed())
382
439
  }
@@ -502,7 +559,7 @@ export class Spoke {
502
559
  if (key === this.openKey) return
503
560
  this.openKey = key
504
561
  const unknown = [...open.keys()].filter((id) => !this.tracker.has(id))
505
- if (unknown.length && this.options.kind === "service" && this.host.listRecent) this.scheduleSeed()
562
+ if (unknown.length && this.host.listRecent) this.scheduleSeed()
506
563
  else if (unknown.length) {
507
564
  for (const id of unknown) this.tracker.ensure(id, Date.now())
508
565
  void this.fetchInfo()
@@ -825,7 +882,13 @@ export class Spoke {
825
882
  opencodeVersion: this.host.opencodeVersion,
826
883
  pluginVersion: this.options.pluginVersion,
827
884
  // Hub messages and prompts need OpenCode's synthetic messages.
828
- features: SPOKE_FEATURES.filter((f) => (f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined),
885
+ // Fork and revert need OpenCode's own server (the plugin API has neither).
886
+ features: SPOKE_FEATURES.filter(
887
+ (f) =>
888
+ ((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
889
+ (f !== "fork" || this.host.forkSession !== undefined) &&
890
+ (f !== "revert" || this.canRevert()),
891
+ ),
829
892
  harness: HARNESS_OPENCODE,
830
893
  harnessVersion: this.host.opencodeVersion,
831
894
  evidence: "native",
@@ -905,6 +968,13 @@ export class Spoke {
905
968
  private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
906
969
  const entry = this.conversations.get(sessionID)
907
970
  const session = await this.host.getSession(sessionID)
971
+ // (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
972
+ // never escapes its restricted agent, and the context hook adds no orchestrator parts).
973
+ // A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
974
+ if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
975
+ this.conversations.set(sessionID, {})
976
+ return { agent: session.agent ?? (await this.defaultAgent()) }
977
+ }
908
978
  this.rememberModel(sessionID, session.model)
909
979
  const current = session.agent
910
980
  const models = await this.voiceModelOn(sessionID, session, entry)
@@ -1023,9 +1093,10 @@ export class Spoke {
1023
1093
  return this.modelPrefsResult(req.sessionID)
1024
1094
  }
1025
1095
  case "timeline": {
1026
- // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
1096
+ // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
1097
+ // staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
1027
1098
  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)
1099
+ const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.shownMessages(req.sessionID)), first)
1029
1100
  if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
1030
1101
  const offset = req.cursor === undefined ? 0 : Number(req.cursor)
1031
1102
  if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
@@ -1069,6 +1140,134 @@ export class Spoke {
1069
1140
  return this.setConversation(req.sessionID, req.on)
1070
1141
  case "deliver":
1071
1142
  return this.deliver(req)
1143
+ case "sessionContext": {
1144
+ // Only a session this process reports (a context needs a real session).
1145
+ if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1146
+ const result = this.contexts.handle(req)
1147
+ // The hub sees the new revision (and the session's agent) in the next sessions frame.
1148
+ void this.refreshLockedAgents().finally(() => this.scheduleSnapshot())
1149
+ return result
1150
+ }
1151
+ case "profileApply":
1152
+ // Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
1153
+ if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1154
+ return this.profiles.handle(req)
1155
+ case "fork":
1156
+ return this.fork(req.sessionID, req.messageID)
1157
+ case "revertPreview": {
1158
+ const messages = await this.host.context(req.sessionID)
1159
+ const index = userIndex(messages, req.messageID)
1160
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1161
+ return revertPreview(messages, index) satisfies ResultFor["revertPreview"]
1162
+ }
1163
+ case "revert":
1164
+ return this.revert(req.sessionID, req.messageID, req.files)
1165
+ case "revertRestore":
1166
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1167
+ await this.host.revertClear!(req.sessionID)
1168
+ return this.revertDone(req.sessionID)
1169
+ case "revertCommit":
1170
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1171
+ await this.host.revertCommit!(req.sessionID)
1172
+ return this.revertDone(req.sessionID)
1173
+ }
1174
+ }
1175
+
1176
+ private canRevert(): boolean {
1177
+ return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
1178
+ }
1179
+
1180
+ /**
1181
+ * Forks the session before one of its user messages (feature "fork") and reports the new session at once (the
1182
+ * snapshot goes out before the reply). The result carries the message's text and attachments for the composer.
1183
+ */
1184
+ private async fork(sessionID: string, messageID: string): Promise<ResultFor["fork"]> {
1185
+ const fork = this.host.forkSession?.bind(this.host)
1186
+ if (!fork) throw new Error(CANNOT_FORK)
1187
+ const messages = await this.host.context(sessionID)
1188
+ const index = userIndex(messages, messageID)
1189
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1190
+ const session = await fork({ sessionID, before: messageID })
1191
+ if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
1192
+ const title = typeof session.title === "string" ? session.title : ""
1193
+ const directory = typeof session.location?.directory === "string" && session.location.directory ? session.location.directory : (this.tracker.directoryOf(sessionID) ?? this.host.directory)
1194
+ const now = Date.now()
1195
+ const source = this.tracker.snapshot(now).find((s) => s.id === sessionID)
1196
+ this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title, location: { directory } } }, now)
1197
+ this.tracker.setInfo(session.id, {
1198
+ title,
1199
+ directory,
1200
+ updated: now,
1201
+ ...(source?.project ? { project: source.project } : {}),
1202
+ ...(source?.projectName ? { projectName: source.projectName } : {}),
1203
+ })
1204
+ if (this.config.mode === "session") this.setSession(session.id, true)
1205
+ this.sendSnapshot()
1206
+ return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
1207
+ }
1208
+
1209
+ /**
1210
+ * Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
1211
+ * `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
1212
+ */
1213
+ private async revert(sessionID: string, messageID: string, files: boolean): Promise<ResultFor["revert"]> {
1214
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1215
+ const messages = await this.host.context(sessionID)
1216
+ const index = userIndex(messages, messageID)
1217
+ if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1218
+ if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new Error(REVERT_BUSY)
1219
+ let staged: unknown
1220
+ try {
1221
+ staged = await this.host.revertStage!({ sessionID, messageID, files })
1222
+ } catch (cause) {
1223
+ if (busyError(cause)) throw new Error(REVERT_BUSY)
1224
+ throw cause
1225
+ }
1226
+ const revert = revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!
1227
+ this.timelines.invalidate(sessionID)
1228
+ // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1229
+ this.tracker.setRevert(sessionID, revert)
1230
+ this.sendSnapshot()
1231
+ return { revert, ...userContent(messages[index]) }
1232
+ }
1233
+
1234
+ /** After a restore or commit: no revert is staged; reported at once. */
1235
+ private revertDone(sessionID: string): Record<string, never> {
1236
+ this.timelines.invalidate(sessionID)
1237
+ this.tracker.setRevert(sessionID, undefined)
1238
+ this.sendSnapshot()
1239
+ return {}
1240
+ }
1241
+
1242
+ /**
1243
+ * The session's messages a timeline shows: all of them, or with a staged revert those before its message. The
1244
+ * revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
1245
+ */
1246
+ private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
1247
+ const [messages, session] = await Promise.all([
1248
+ this.host.context(sessionID),
1249
+ this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
1250
+ ])
1251
+ if (!this.canRevert()) return messages
1252
+ const raw = session ? (session as { revert?: unknown }).revert : undefined
1253
+ const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
1254
+ if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1255
+ return visibleMessages(messages, revert?.messageID)
1256
+ }
1257
+
1258
+ /** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
1259
+ private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1260
+ if (!this.tracker.has(sessionID)) return
1261
+ if (event.type !== "session.revert.staged") {
1262
+ if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
1263
+ return
1264
+ }
1265
+ try {
1266
+ const raw = (event.data as Record<string, unknown>).revert
1267
+ const revert = revertSummary(raw, await this.host.context(sessionID))
1268
+ if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1269
+ } catch (cause) {
1270
+ console.error("[hub] could not read a staged revert", cause)
1072
1271
  }
1073
1272
  }
1074
1273
 
@@ -1081,11 +1280,11 @@ export class Spoke {
1081
1280
  if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1082
1281
  return this.deliveries.run(req.messageID, req.wake, async () => {
1083
1282
  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
- })
1283
+ const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
1284
+ // A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
1285
+ // pending and sends it again after the turn (the turn's end sends a sessions frame). Nothing is admitted.
1286
+ if (busy && deferredWhileBusy(req, subagent)) throw new Error(QUIET_WHILE_BUSY)
1287
+ const plan = planDelivery(req, { busy, formOpen: this.tracker.formOpenIn(req.sessionID), subagent })
1089
1288
  const id = harnessMessageID(req.messageID)
1090
1289
  // Marked before sending: OpenCode may report it delivered before the call returns.
1091
1290
  this.origins.hub(id)
@@ -1488,6 +1687,7 @@ export class Spoke {
1488
1687
  if (event.type === "session.deleted" && typeof sessionID === "string") {
1489
1688
  this.tokens.delete(sessionID)
1490
1689
  this.actTokens.delete(sessionID)
1690
+ this.profiles.forget(sessionID)
1491
1691
  }
1492
1692
  if (event.type === "form.replied" || event.type === "form.cancelled") {
1493
1693
  const formID = (event.data as Record<string, unknown> | undefined)?.id
@@ -1502,7 +1702,9 @@ export class Spoke {
1502
1702
  if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
1503
1703
  }
1504
1704
  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) {
1705
+ if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
1706
+ if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
1707
+ if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
1506
1708
  this.scheduleSeed()
1507
1709
  }
1508
1710
  void this.fetchInfo()
@@ -1510,7 +1712,11 @@ export class Spoke {
1510
1712
  // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1511
1713
  // get their session from the job.
1512
1714
  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 })
1715
+ if (compact && shouldForward(this.config, this.tracker, compact)) {
1716
+ this.announce(compact)
1717
+ this.connection.send({ t: "event", event: compact })
1718
+ if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1719
+ }
1514
1720
  }
1515
1721
  } catch (cause) {
1516
1722
  console.error("[hub] event handling failed", cause)
@@ -1599,13 +1805,52 @@ export class Spoke {
1599
1805
  }
1600
1806
 
1601
1807
  private async seed(): Promise<void> {
1808
+ if (this.listing) {
1809
+ this.relist = true
1810
+ return
1811
+ }
1812
+ this.listing = true
1813
+ try {
1814
+ do {
1815
+ this.relist = false
1816
+ await this.listOnce()
1817
+ } while (this.relist && this.started)
1818
+ } finally {
1819
+ this.listing = false
1820
+ }
1821
+ }
1822
+
1823
+ /**
1824
+ * One listing, bounded by listingTimeoutMs: a server that accepts and never answers must not stop later listings
1825
+ * (seed() runs one at a time). A timed-out listing is abandoned (logged once until a listing completes again);
1826
+ * if it settles later its result is dropped, so it never overwrites a newer listing.
1827
+ */
1828
+ private async listOnce(): Promise<void> {
1829
+ const generation = ++this.listingGeneration
1830
+ const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
1831
+ let timer: ReturnType<typeof setTimeout> | undefined
1832
+ const timedOut = Symbol("timeout")
1833
+ const cancel = new AbortController()
1602
1834
  try {
1603
1835
  const keep = this.keep()
1604
1836
  const { days, max } = this.config.list
1605
- const sessions = (await this.host.listRecent?.({ days, max, include: [...keep] })) ?? []
1837
+ const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep], signal: cancel.signal }))
1838
+ listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
1839
+ const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
1840
+ if (result === timedOut) {
1841
+ cancel.abort(new Error(`listing timed out after ${limit} ms`))
1842
+ if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
1843
+ this.listingHung = true
1844
+ return
1845
+ }
1846
+ this.listingHung = false
1847
+ if (generation !== this.listingGeneration) return
1848
+ const sessions = result ?? []
1606
1849
  if (this.tracker.applyListing(sessions, Date.now(), keep)) this.scheduleSnapshot()
1607
1850
  } catch (cause) {
1608
1851
  console.error("[hub] could not list recent sessions", errorText(cause))
1852
+ } finally {
1853
+ clearTimeout(timer)
1609
1854
  }
1610
1855
  }
1611
1856
 
@@ -1625,9 +1870,68 @@ export class Spoke {
1625
1870
  }, this.options.snapshotDebounceMs ?? 500)
1626
1871
  }
1627
1872
 
1873
+ /**
1874
+ * (CON-04) The agent a locked (Concierge) session runs on, as the context hook or OpenCode reports it: a change
1875
+ * goes to the hub in the next sessions frame (the hub suspends a Concierge on another agent).
1876
+ */
1877
+ noteAgent(sessionID: string, agent: string | undefined): void {
1878
+ if (this.contexts.seenAgent(sessionID, agent)) this.scheduleSnapshot()
1879
+ }
1880
+
1881
+ /** Reads the current agent of every locked session this process reports (OpenCode's session record). */
1882
+ private async refreshLockedAgents(): Promise<void> {
1883
+ for (const sessionID of this.contexts.lockedSessions()) {
1884
+ if (!this.tracker.has(sessionID)) continue
1885
+ try {
1886
+ this.noteAgent(sessionID, (await this.host.getSession(sessionID)).agent)
1887
+ } catch {
1888
+ // Gone or unreachable: the next refresh tries again.
1889
+ }
1890
+ }
1891
+ }
1892
+
1628
1893
  private sendSnapshot(): void {
1894
+ if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
1895
+ this.snapshotTimer = undefined
1629
1896
  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) }) })
1897
+ const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }).map((s) => ({ ...s, ...this.contexts.report(s.id) }))
1898
+ this.connection?.send({ t: "sessions", sessions })
1899
+ this.announced = new Set(sessions.map((s) => s.id))
1900
+ this.resendUnheardFailures()
1901
+ }
1902
+
1903
+ /**
1904
+ * A run that failed while its session's details were still being fetched went out before any snapshot listed the
1905
+ * session, so the hub dropped it: once a snapshot lists the session, its failure goes out again (unless a run
1906
+ * started since).
1907
+ */
1908
+ private resendUnheardFailures(): void {
1909
+ for (const id of [...this.unheardFailures]) {
1910
+ const error = this.tracker.errorOf(id)
1911
+ if (!error || !this.tracker.has(id)) {
1912
+ this.unheardFailures.delete(id)
1913
+ continue
1914
+ }
1915
+ if (!this.announced.has(id) || this.connection?.state !== "connected") continue
1916
+ this.unheardFailures.delete(id)
1917
+ const event = { type: "session.execution.failed", data: { sessionID: id, error } }
1918
+ if (shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
1919
+ }
1920
+ }
1921
+
1922
+ /**
1923
+ * Sends the snapshot now when an event is about a session the hub was not told about yet: the hub drops a run's
1924
+ * events (a failure, a permission) for sessions it does not know, and a run can fail within milliseconds of
1925
+ * session.created (a model that does not exist), well before the debounced snapshot. Creating, renaming or
1926
+ * deleting a session alone waits for the debounce (a burst of subagents sends one snapshot).
1927
+ */
1928
+ private announce(event: { type: string; data: Record<string, unknown> }): void {
1929
+ if (event.type === "session.created" || event.type === "session.renamed" || event.type === "session.deleted") return
1930
+ const sessionID = eventSessionID(event)
1931
+ if (!sessionID || this.announced.has(sessionID) || !this.tracker.hasInfo(sessionID)) return
1932
+ this.sendSnapshot()
1933
+ // Once per session even when the snapshot's limits leave it out.
1934
+ this.announced.add(sessionID)
1631
1935
  }
1632
1936
 
1633
1937
  private every(ms: number, run: () => void): void {
@@ -1637,6 +1941,9 @@ export class Spoke {
1637
1941
  }
1638
1942
  }
1639
1943
 
1944
+ /** Longest a session listing may take (SpokeOptions.listingTimeoutMs). */
1945
+ const LISTING_TIMEOUT_MS = 60_000
1946
+
1640
1947
  /** Directories whose shells are listed when seeding. */
1641
1948
  const SEED_DIRECTORIES = 20
1642
1949
 
@@ -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