@huynle/hub 0.8.0 → 0.8.2

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 (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -33,12 +33,12 @@ 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
+ import { copiesHubIdentity, forkIdentityNote, notAUserMessage, opError, REVERT_BUSY, REVERT_END_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages, withStagedAt } from "./fork-revert"
37
37
  import { TurnTracker } from "../turns"
38
38
  import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
39
- import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
39
+ import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
40
40
  import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
41
- import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY } from "./deliver"
41
+ import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
42
42
  import { pollEnrollment, startEnrollment } from "./enroll"
43
43
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
44
44
  import type { LocalHubResult } from "./localhub"
@@ -147,17 +147,21 @@ export interface SpokeHost {
147
147
  * only those of the locations it loaded. Absent (a TUI's own server, which this process cannot reach): the
148
148
  * process reports only the sessions it sees active.
149
149
  */
150
- 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>>
151
152
  /** Creates a session in a directory (service processes only). */
152
153
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
153
154
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
154
155
  mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
155
- /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
156
- 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>
157
158
  /** Current branch of a directory. */
158
159
  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[] }>
160
+ /**
161
+ * (HWA-16) A location's local skill and command names (they win over a hub profile's), read in that folder;
162
+ * undefined: unknown (the profile's skills and commands are not offered). Absent: none applied.
163
+ */
164
+ localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] } | undefined>
161
165
  /** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
162
166
  readonly profileTool?: boolean
163
167
  /**
@@ -187,6 +191,8 @@ const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from t
187
191
  export interface SpokeOptions {
188
192
  /** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
189
193
  readonly conciergeLockFile?: string
194
+ /** Other Concierge lock files read, never written (the other default state dir places; FX-08, fail closed). */
195
+ readonly conciergeLockReadFiles?: readonly string[]
190
196
  readonly configFile: string
191
197
  readonly machineName: string
192
198
  readonly kind: ProcessKind
@@ -195,6 +201,8 @@ export interface SpokeOptions {
195
201
  readonly pluginVersion: string
196
202
  readonly turnTimeoutMs?: number
197
203
  readonly snapshotDebounceMs?: number
204
+ /** How long a fork waits for its hub identity note to go in (default FORK_NOTE_TIMEOUT_MS). */
205
+ readonly forkNoteTimeoutMs?: number
198
206
  readonly snapshotIntervalMs?: number
199
207
  readonly seedIntervalMs?: number
200
208
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
@@ -295,6 +303,8 @@ const FALLBACK_AGENT = "build"
295
303
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
296
304
  const ENROLL_RETRY_MS = 500
297
305
  const NOT_SHARED = "This session is not shared with voice"
306
+ /** How long a fork waits for its hub identity note (a synthetic message) before it answers without it. */
307
+ export const FORK_NOTE_TIMEOUT_MS = 5_000
298
308
  const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
299
309
  const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
300
310
  /** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
@@ -337,6 +347,8 @@ export class Spoke {
337
347
  private readonly turns = new TurnTracker()
338
348
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
339
349
  private readonly timelines = new TimelineCache()
350
+ /** Per session: a counter of revert events (a staged event's late read must not undo a later one). */
351
+ private readonly revertSeq = new Map<string, number>()
340
352
  private readonly voiceTurns = new Map<string, number>()
341
353
  private readonly running = new Set<string>()
342
354
  /** Hub messages applied (by messageID), so a retry is not admitted again. */
@@ -410,7 +422,10 @@ export class Spoke {
410
422
  ) {
411
423
  this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
412
424
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
413
- this.contexts = new SessionContexts(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {})
425
+ this.contexts = new SessionContexts({
426
+ ...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
427
+ ...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
428
+ })
414
429
  const localNames = host.localNames?.bind(host)
415
430
  this.profiles = new OpenCodeProfiles({
416
431
  isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
@@ -1052,7 +1067,8 @@ export class Spoke {
1052
1067
  case "form":
1053
1068
  return this.form("cancel" in req ? { sessionID: req.sessionID, formID: req.formID, cancel: true } : { sessionID: req.sessionID, formID: req.formID, answer: req.answer })
1054
1069
  case "context":
1055
- return { messages: contextMessages(await this.host.context(req.sessionID)) }
1070
+ // A staged revert's hidden messages are not part of the conversation any more.
1071
+ return { messages: contextMessages(await this.shownMessages(req.sessionID)) }
1056
1072
  case "models": {
1057
1073
  const raw = await this.host.listModels?.() ?? []
1058
1074
  return {
@@ -1146,27 +1162,45 @@ export class Spoke {
1146
1162
  // Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
1147
1163
  if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1148
1164
  return this.profiles.handle(req)
1165
+ // Fork and revert: OpenCode's busy and not-found failures answer coded refusals (Res.code).
1149
1166
  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
- }
1167
+ return this.fork(req.sessionID, req.messageID).catch((cause) => {
1168
+ throw opError(cause, REVERT_BUSY)
1169
+ })
1170
+ case "revertPreview":
1171
+ return this.revertPreview(req.sessionID, req.messageID).catch((cause) => {
1172
+ throw opError(cause, REVERT_BUSY)
1173
+ })
1157
1174
  case "revert":
1158
- return this.revert(req.sessionID, req.messageID, req.files)
1175
+ return this.revert(req.sessionID, req.messageID, req.files).catch((cause) => {
1176
+ throw opError(cause, REVERT_BUSY)
1177
+ })
1159
1178
  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
1179
  case "revertCommit":
1164
- if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1165
- await this.host.revertCommit!(req.sessionID)
1166
- return this.revertDone(req.sessionID)
1180
+ return this.revertEnd(req.sessionID, req.op).catch((cause) => {
1181
+ throw opError(cause, REVERT_END_BUSY)
1182
+ })
1167
1183
  }
1168
1184
  }
1169
1185
 
1186
+ private async revertPreview(sessionID: string, messageID: string): Promise<ResultFor["revertPreview"]> {
1187
+ const messages = await this.host.context(sessionID)
1188
+ const index = userIndex(messages, messageID)
1189
+ if (index < 0) throw notAUserMessage()
1190
+ return revertPreview(messages, index)
1191
+ }
1192
+
1193
+ /** Restores (revert.clear) or commits a staged revert; refused while the session runs (OpenCode refuses it too). */
1194
+ private async revertEnd(sessionID: string, op: "revertRestore" | "revertCommit"): Promise<Record<string, never>> {
1195
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1196
+ if (op === "revertRestore") await this.host.revertClear!(sessionID)
1197
+ else {
1198
+ await this.host.revertCommit!(sessionID)
1199
+ this.recordCommit(sessionID)
1200
+ }
1201
+ return this.revertDone(sessionID)
1202
+ }
1203
+
1170
1204
  private canRevert(): boolean {
1171
1205
  return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
1172
1206
  }
@@ -1180,7 +1214,7 @@ export class Spoke {
1180
1214
  if (!fork) throw new Error(CANNOT_FORK)
1181
1215
  const messages = await this.host.context(sessionID)
1182
1216
  const index = userIndex(messages, messageID)
1183
- if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1217
+ if (index < 0) throw notAUserMessage()
1184
1218
  const session = await fork({ sessionID, before: messageID })
1185
1219
  if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
1186
1220
  const title = typeof session.title === "string" ? session.title : ""
@@ -1197,9 +1231,34 @@ export class Spoke {
1197
1231
  })
1198
1232
  if (this.config.mode === "session") this.setSession(session.id, true)
1199
1233
  this.sendSnapshot()
1234
+ if (copiesHubIdentity(messages, index)) await this.forkNote(session.id, forkIdentityNote(sessionID, source?.title))
1200
1235
  return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
1201
1236
  }
1202
1237
 
1238
+ /**
1239
+ * OpenCode's fork copies the source's hub primer (its hub name and roles), and has no way to drop a message: the
1240
+ * fork gets a hub note (admitted without a turn, like a primer) that the identity is not its own. Best effort.
1241
+ */
1242
+ private async forkNote(forkID: string, text: string): Promise<void> {
1243
+ const synthetic = this.host.synthetic?.bind(this.host)
1244
+ if (!synthetic) return
1245
+ const id = harnessMessageID(`fork-note:${forkID}`)
1246
+ let timer: ReturnType<typeof setTimeout> | undefined
1247
+ const ms = this.options.forkNoteTimeoutMs ?? FORK_NOTE_TIMEOUT_MS
1248
+ try {
1249
+ this.origins.hub(id)
1250
+ // Never holds the fork's answer for long: a note that is slow to go in is given up (logged).
1251
+ await Promise.race([
1252
+ synthetic({ sessionID: forkID, id, text, metadata: { source: PROMPT_SOURCE, messageID: id, kind: "primer", from: "hub", to: forkID }, delivery: "steer", resume: false }),
1253
+ new Promise((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer in ${ms} ms`)), ms))),
1254
+ ])
1255
+ } catch (cause) {
1256
+ console.error("[hub] could not tell a fork that its hub identity is not its own", cause)
1257
+ } finally {
1258
+ clearTimeout(timer)
1259
+ }
1260
+ }
1261
+
1203
1262
  /**
1204
1263
  * Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
1205
1264
  * `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
@@ -1208,16 +1267,10 @@ export class Spoke {
1208
1267
  if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1209
1268
  const messages = await this.host.context(sessionID)
1210
1269
  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)!
1270
+ if (index < 0) throw notAUserMessage()
1271
+ if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new CodedError(REVERT_BUSY, "busy")
1272
+ const staged = await this.host.revertStage!({ sessionID, messageID, files })
1273
+ const revert = withStagedAt(revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!, this.tracker.revertOf(sessionID), Date.now())
1221
1274
  this.timelines.invalidate(sessionID)
1222
1275
  // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1223
1276
  this.tracker.setRevert(sessionID, revert)
@@ -1244,21 +1297,52 @@ export class Spoke {
1244
1297
  ])
1245
1298
  if (!this.canRevert()) return messages
1246
1299
  const raw = session ? (session as { revert?: unknown }).revert : undefined
1247
- const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
1300
+ const known = this.tracker.revertOf(sessionID)
1301
+ const read = session ? revertSummary(raw, messages) : known
1302
+ const revert = session && read ? withStagedAt(read, known, Date.now()) : read
1248
1303
  if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1249
1304
  return visibleMessages(messages, revert?.messageID)
1250
1305
  }
1251
1306
 
1252
- /** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
1307
+ /**
1308
+ * Records a commit of the session's revert (op or OpenCode's committed event) as its removed window
1309
+ * (SessionInfo.revertCommitted). Without a known staged revert: the event of a commit already recorded changes
1310
+ * nothing; otherwise a window without a start (the console still reloads on it). Returns true when it changed.
1311
+ */
1312
+ private recordCommit(sessionID: string, to?: string): boolean {
1313
+ const staged = this.tracker.revertOf(sessionID)
1314
+ const messageID = staged?.messageID ?? to
1315
+ if (!messageID) return false
1316
+ if (!staged && this.tracker.committedOf(sessionID)?.messageID === messageID) return false
1317
+ const now = Date.now()
1318
+ return this.tracker.setCommitted(sessionID, { messageID, ...(staged?.at === undefined ? {} : { from: staged.at }), until: staged?.stagedAt ?? now, at: now })
1319
+ }
1320
+
1321
+ /**
1322
+ * A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. A staged
1323
+ * event reads the history; a later event that arrives meanwhile (a prompt committing it at once) wins, and the
1324
+ * late read only fills in the commit's window start.
1325
+ */
1253
1326
  private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1254
1327
  if (!this.tracker.has(sessionID)) return
1328
+ const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
1329
+ this.revertSeq.set(sessionID, seq)
1255
1330
  if (event.type !== "session.revert.staged") {
1256
- if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
1331
+ const data = event.data as Record<string, unknown>
1332
+ const committed = event.type === "session.revert.committed" && this.recordCommit(sessionID, typeof data.to === "string" && data.to ? data.to : undefined)
1333
+ if (committed) this.timelines.invalidate(sessionID)
1334
+ if (this.tracker.setRevert(sessionID, undefined) || committed) this.scheduleSnapshot()
1257
1335
  return
1258
1336
  }
1259
1337
  try {
1260
1338
  const raw = (event.data as Record<string, unknown>).revert
1261
- const revert = revertSummary(raw, await this.host.context(sessionID))
1339
+ const read = revertSummary(raw, await this.host.context(sessionID))
1340
+ if (this.revertSeq.get(sessionID) !== seq) {
1341
+ const commit = this.tracker.committedOf(sessionID)
1342
+ if (commit && commit.from === undefined && read?.at !== undefined && read.messageID === commit.messageID && this.tracker.setCommitted(sessionID, { ...commit, from: read.at })) this.scheduleSnapshot()
1343
+ return
1344
+ }
1345
+ const revert = read && withStagedAt(read, this.tracker.revertOf(sessionID), Date.now())
1262
1346
  if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1263
1347
  } catch (cause) {
1264
1348
  console.error("[hub] could not read a staged revert", cause)
@@ -1273,6 +1357,8 @@ export class Spoke {
1273
1357
  const synthetic = this.host.synthetic?.bind(this.host)
1274
1358
  if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1275
1359
  return this.deliveries.run(req.messageID, req.wake, async () => {
1360
+ // Defence in depth: the hub holds deliveries while it sees the revert; this process may know it first.
1361
+ if (this.tracker.revertOf(req.sessionID)) throw new Error(REVERT_STAGED)
1276
1362
  const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
1277
1363
  const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
1278
1364
  // A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
@@ -1448,7 +1534,7 @@ export class Spoke {
1448
1534
  }
1449
1535
  const host = this.host
1450
1536
  const [messages, models, mcp, diffs, branch] = await Promise.all([
1451
- optional(() => host.context(sessionID)),
1537
+ optional(() => this.shownMessages(sessionID)),
1452
1538
  optional(host.listModels && (() => host.listModels!())),
1453
1539
  optional(host.mcpList && (() => host.mcpList!(directory))),
1454
1540
  optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
@@ -1824,13 +1910,15 @@ export class Spoke {
1824
1910
  const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
1825
1911
  let timer: ReturnType<typeof setTimeout> | undefined
1826
1912
  const timedOut = Symbol("timeout")
1913
+ const cancel = new AbortController()
1827
1914
  try {
1828
1915
  const keep = this.keep()
1829
1916
  const { days, max } = this.config.list
1830
- const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep] }))
1917
+ const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep], signal: cancel.signal }))
1831
1918
  listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
1832
1919
  const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
1833
1920
  if (result === timedOut) {
1921
+ cancel.abort(new Error(`listing timed out after ${limit} ms`))
1834
1922
  if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
1835
1923
  this.listingHung = true
1836
1924
  return
@@ -1,4 +1,4 @@
1
- import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, summarizeToolInput, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
1
+ import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, redactEmbeddedMedia, 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. */
@@ -45,6 +45,11 @@ export interface TimelineItem {
45
45
  /** Error and retry rows: the error's type (bounded) and HTTP status (100–599). */
46
46
  readonly errorType?: string
47
47
  readonly errorStatus?: number
48
+ /**
49
+ * (R-04) Error rows: a permission was denied and that stopped the turn (OpenCode fails the declined tool call with
50
+ * "The user declined this tool call" and the step with "Step interrupted"). Whoever denied it is the hub's audit's.
51
+ */
52
+ readonly declined?: true
48
53
  /** Retry rows: the attempt number and when the next attempt starts (epoch ms). */
49
54
  readonly attempt?: number
50
55
  readonly next?: number
@@ -56,6 +61,14 @@ const SUBAGENT_TOOLS = new Set(["subagent", "task"])
56
61
  const TITLE_MAX = 160
57
62
  const ERROR_TEXT_MAX = 1000
58
63
  const ERROR_TYPE_MAX = 64
64
+ /**
65
+ * OpenCode's error on a tool call the user (or an agent answering for them) denied, and on the step it then ends
66
+ * (2.2.3, SessionStep: failTool(call, {type: "aborted", message: DECLINED_TOOL}), failAssistant({type: "aborted",
67
+ * message: "Step interrupted"})). Both types are matched, so a tool that throws the same text (tool.execution) cannot
68
+ * mark its turn denied.
69
+ */
70
+ const DECLINED_TOOL = "The user declined this tool call"
71
+ const ABORTED = "aborted"
59
72
 
60
73
  const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
61
74
 
@@ -149,7 +162,7 @@ function errorFields(raw: unknown, fallback: string): { text: string; errorType?
149
162
  */
150
163
  function statusRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineItem | undefined {
151
164
  const base = { messageID: message.id as string, sessionID, ...(at === undefined ? {} : { at }), pos }
152
- if (message.error != null) return { key: `${message.id}:error`, kind: "error", ...errorFields(message.error, "failed"), ...base }
165
+ if (message.error != null) return { key: `${message.id}:error`, kind: "error", ...errorFields(message.error, "failed"), ...(declinedTool(message) ? { declined: true as const } : {}), ...base }
153
166
  if (message.retry == null || message.time?.completed != null) return undefined
154
167
  const retry = record(message.retry)
155
168
  const next = millis(retry.at)
@@ -163,6 +176,16 @@ function statusRow(sessionID: string, message: Record<string, any>, at: number |
163
176
  }
164
177
  }
165
178
 
179
+ /** (R-04) Whether the message's step was aborted because one of its tool calls' permission was denied. */
180
+ function declinedTool(message: Record<string, any>): boolean {
181
+ if (!Array.isArray(message.content) || record(message.error).type !== ABORTED) return false
182
+ return message.content.some((part: any) => {
183
+ if (part?.type !== "tool" || part.state?.status !== "error") return false
184
+ const error = record(part.state.error)
185
+ return error.type === ABORTED && error.message === DECLINED_TOOL
186
+ })
187
+ }
188
+
166
189
  /** A message the hub delivered (synthetic, with deliverMetadata): a "message" row; other synthetic messages: none. */
167
190
  function hubMessageRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineMessageItem | undefined {
168
191
  const meta = record(message.metadata)
@@ -325,8 +348,12 @@ export function timelinePart(messages: readonly unknown[], messageID: string, pa
325
348
  }
326
349
 
327
350
  const DETAIL_MAX_BYTES = 750_000
351
+ /**
352
+ * A tool call's detail for the console: embedded images (tool screenshots as data: URLs or base64 parts) become
353
+ * small placeholders, so they never count against the limit nor travel to the console; huge text still fails.
354
+ */
328
355
  export function boundedTimelinePart(messages: readonly unknown[], messageID: string, partID: string): unknown {
329
- const detail = timelinePart(messages, messageID, partID)
356
+ const detail = redactEmbeddedMedia(timelinePart(messages, messageID, partID))
330
357
  if (JSON.stringify(detail).length > DETAIL_MAX_BYTES) throw new Error("Timeline part is too large to display")
331
358
  return detail
332
359
  }
@@ -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 SessionRevertInfo, type TerminalInfo } from "../protocol"
4
+ import { compactActivity, compactRunError, FORWARDED_EVENTS, type CompactRunError, type CompactSessionStatus, type JobInfo, type SessionInfo, type RevertCommitted, 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
@@ -37,6 +37,8 @@ interface Entry {
37
37
  listed?: boolean
38
38
  /** A staged revert (feature "revert"). */
39
39
  revert?: SessionRevertInfo
40
+ /** The latest committed revert's removed window (feature "revert"). */
41
+ committed?: RevertCommitted
40
42
  }
41
43
 
42
44
  const ACTIVITY = new Set([
@@ -199,6 +201,19 @@ export class SessionTracker {
199
201
  return true
200
202
  }
201
203
 
204
+ /** The session's latest committed revert window, if any. */
205
+ committedOf(id: string): RevertCommitted | undefined {
206
+ return this.entries.get(id)?.committed
207
+ }
208
+
209
+ /** Sets a known session's latest committed revert window. Returns true when it changed. */
210
+ setCommitted(id: string, committed: RevertCommitted): boolean {
211
+ const entry = this.entries.get(id)
212
+ if (!entry || JSON.stringify(entry.committed ?? null) === JSON.stringify(committed)) return false
213
+ entry.committed = committed
214
+ return true
215
+ }
216
+
202
217
  /** Fills in details fetched for a session that was first seen through an activity event. */
203
218
  setInfo(id: string, details: SessionDetails): boolean {
204
219
  const entry = this.entries.get(id)
@@ -405,6 +420,7 @@ export class SessionTracker {
405
420
  ...(e.project ? { project: e.project } : {}),
406
421
  ...(e.projectName ? { projectName: e.projectName } : {}),
407
422
  ...(e.revert ? { revert: e.revert } : {}),
423
+ ...(e.committed ? { revertCommitted: e.committed } : {}),
408
424
  }))
409
425
  }
410
426
 
package/src/status.ts CHANGED
@@ -23,7 +23,8 @@ export interface Blocker {
23
23
  export interface BlockerRecommendation {
24
24
  /** Who recommends it, as the hub names the agent. */
25
25
  readonly by: string
26
- readonly recommends: "approve"
26
+ /** (R-03) Absent: the escalation recommends nothing (a Concierge that only tried); the blocker just links the Decision. */
27
+ readonly recommends?: "approve"
27
28
  readonly why?: string
28
29
  /** The Decision the user answers it in. */
29
30
  readonly decisionID?: string
@@ -0,0 +1 @@
1
+ import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{L as o,R as s,z as c}from"./index-g4V9mfSr.js";import{t as l}from"./ArtifactWorkbench-sP2mZccC.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
@@ -0,0 +1 @@
1
+ import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{t as o}from"./ArtifactWorkbench-sP2mZccC.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};