@huynle/hub 0.8.1 → 0.8.3

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 (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -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"
@@ -157,8 +157,11 @@ export interface SpokeHost {
157
157
  vcsDiff?(directory: string): Promise<ReadonlyArray<unknown> | undefined>
158
158
  /** Current branch of a directory. */
159
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[] }>
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>
162
165
  /** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
163
166
  readonly profileTool?: boolean
164
167
  /**
@@ -198,6 +201,8 @@ export interface SpokeOptions {
198
201
  readonly pluginVersion: string
199
202
  readonly turnTimeoutMs?: number
200
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
201
206
  readonly snapshotIntervalMs?: number
202
207
  readonly seedIntervalMs?: number
203
208
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
@@ -298,6 +303,8 @@ const FALLBACK_AGENT = "build"
298
303
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
299
304
  const ENROLL_RETRY_MS = 500
300
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
301
308
  const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
302
309
  const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
303
310
  /** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
@@ -340,6 +347,8 @@ export class Spoke {
340
347
  private readonly turns = new TurnTracker()
341
348
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
342
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>()
343
352
  private readonly voiceTurns = new Map<string, number>()
344
353
  private readonly running = new Set<string>()
345
354
  /** Hub messages applied (by messageID), so a retry is not admitted again. */
@@ -1058,7 +1067,8 @@ export class Spoke {
1058
1067
  case "form":
1059
1068
  return this.form("cancel" in req ? { sessionID: req.sessionID, formID: req.formID, cancel: true } : { sessionID: req.sessionID, formID: req.formID, answer: req.answer })
1060
1069
  case "context":
1061
- 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)) }
1062
1072
  case "models": {
1063
1073
  const raw = await this.host.listModels?.() ?? []
1064
1074
  return {
@@ -1125,6 +1135,9 @@ export class Spoke {
1125
1135
  return {}
1126
1136
  case "rename":
1127
1137
  return this.rename(req.sessionID, req.title)
1138
+ case "dismissError":
1139
+ this.unheardFailures.delete(req.sessionID)
1140
+ return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
1128
1141
  case "autoName":
1129
1142
  return this.autoName(req.sessionID)
1130
1143
  case "stop":
@@ -1152,27 +1165,45 @@ export class Spoke {
1152
1165
  // Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
1153
1166
  if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1154
1167
  return this.profiles.handle(req)
1168
+ // Fork and revert: OpenCode's busy and not-found failures answer coded refusals (Res.code).
1155
1169
  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
- }
1170
+ return this.fork(req.sessionID, req.messageID).catch((cause) => {
1171
+ throw opError(cause, REVERT_BUSY)
1172
+ })
1173
+ case "revertPreview":
1174
+ return this.revertPreview(req.sessionID, req.messageID).catch((cause) => {
1175
+ throw opError(cause, REVERT_BUSY)
1176
+ })
1163
1177
  case "revert":
1164
- return this.revert(req.sessionID, req.messageID, req.files)
1178
+ return this.revert(req.sessionID, req.messageID, req.files).catch((cause) => {
1179
+ throw opError(cause, REVERT_BUSY)
1180
+ })
1165
1181
  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
1182
  case "revertCommit":
1170
- if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1171
- await this.host.revertCommit!(req.sessionID)
1172
- return this.revertDone(req.sessionID)
1183
+ return this.revertEnd(req.sessionID, req.op).catch((cause) => {
1184
+ throw opError(cause, REVERT_END_BUSY)
1185
+ })
1173
1186
  }
1174
1187
  }
1175
1188
 
1189
+ private async revertPreview(sessionID: string, messageID: string): Promise<ResultFor["revertPreview"]> {
1190
+ const messages = await this.host.context(sessionID)
1191
+ const index = userIndex(messages, messageID)
1192
+ if (index < 0) throw notAUserMessage()
1193
+ return revertPreview(messages, index)
1194
+ }
1195
+
1196
+ /** Restores (revert.clear) or commits a staged revert; refused while the session runs (OpenCode refuses it too). */
1197
+ private async revertEnd(sessionID: string, op: "revertRestore" | "revertCommit"): Promise<Record<string, never>> {
1198
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1199
+ if (op === "revertRestore") await this.host.revertClear!(sessionID)
1200
+ else {
1201
+ await this.host.revertCommit!(sessionID)
1202
+ this.recordCommit(sessionID)
1203
+ }
1204
+ return this.revertDone(sessionID)
1205
+ }
1206
+
1176
1207
  private canRevert(): boolean {
1177
1208
  return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
1178
1209
  }
@@ -1186,7 +1217,7 @@ export class Spoke {
1186
1217
  if (!fork) throw new Error(CANNOT_FORK)
1187
1218
  const messages = await this.host.context(sessionID)
1188
1219
  const index = userIndex(messages, messageID)
1189
- if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1220
+ if (index < 0) throw notAUserMessage()
1190
1221
  const session = await fork({ sessionID, before: messageID })
1191
1222
  if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
1192
1223
  const title = typeof session.title === "string" ? session.title : ""
@@ -1203,9 +1234,34 @@ export class Spoke {
1203
1234
  })
1204
1235
  if (this.config.mode === "session") this.setSession(session.id, true)
1205
1236
  this.sendSnapshot()
1237
+ if (copiesHubIdentity(messages, index)) await this.forkNote(session.id, forkIdentityNote(sessionID, source?.title))
1206
1238
  return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
1207
1239
  }
1208
1240
 
1241
+ /**
1242
+ * OpenCode's fork copies the source's hub primer (its hub name and roles), and has no way to drop a message: the
1243
+ * fork gets a hub note (admitted without a turn, like a primer) that the identity is not its own. Best effort.
1244
+ */
1245
+ private async forkNote(forkID: string, text: string): Promise<void> {
1246
+ const synthetic = this.host.synthetic?.bind(this.host)
1247
+ if (!synthetic) return
1248
+ const id = harnessMessageID(`fork-note:${forkID}`)
1249
+ let timer: ReturnType<typeof setTimeout> | undefined
1250
+ const ms = this.options.forkNoteTimeoutMs ?? FORK_NOTE_TIMEOUT_MS
1251
+ try {
1252
+ this.origins.hub(id)
1253
+ // Never holds the fork's answer for long: a note that is slow to go in is given up (logged).
1254
+ await Promise.race([
1255
+ synthetic({ sessionID: forkID, id, text, metadata: { source: PROMPT_SOURCE, messageID: id, kind: "primer", from: "hub", to: forkID }, delivery: "steer", resume: false }),
1256
+ new Promise((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer in ${ms} ms`)), ms))),
1257
+ ])
1258
+ } catch (cause) {
1259
+ console.error("[hub] could not tell a fork that its hub identity is not its own", cause)
1260
+ } finally {
1261
+ clearTimeout(timer)
1262
+ }
1263
+ }
1264
+
1209
1265
  /**
1210
1266
  * Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
1211
1267
  * `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
@@ -1214,16 +1270,10 @@ export class Spoke {
1214
1270
  if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1215
1271
  const messages = await this.host.context(sessionID)
1216
1272
  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)!
1273
+ if (index < 0) throw notAUserMessage()
1274
+ if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new CodedError(REVERT_BUSY, "busy")
1275
+ const staged = await this.host.revertStage!({ sessionID, messageID, files })
1276
+ const revert = withStagedAt(revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!, this.tracker.revertOf(sessionID), Date.now())
1227
1277
  this.timelines.invalidate(sessionID)
1228
1278
  // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1229
1279
  this.tracker.setRevert(sessionID, revert)
@@ -1250,21 +1300,52 @@ export class Spoke {
1250
1300
  ])
1251
1301
  if (!this.canRevert()) return messages
1252
1302
  const raw = session ? (session as { revert?: unknown }).revert : undefined
1253
- const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
1303
+ const known = this.tracker.revertOf(sessionID)
1304
+ const read = session ? revertSummary(raw, messages) : known
1305
+ const revert = session && read ? withStagedAt(read, known, Date.now()) : read
1254
1306
  if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1255
1307
  return visibleMessages(messages, revert?.messageID)
1256
1308
  }
1257
1309
 
1258
- /** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
1310
+ /**
1311
+ * Records a commit of the session's revert (op or OpenCode's committed event) as its removed window
1312
+ * (SessionInfo.revertCommitted). Without a known staged revert: the event of a commit already recorded changes
1313
+ * nothing; otherwise a window without a start (the console still reloads on it). Returns true when it changed.
1314
+ */
1315
+ private recordCommit(sessionID: string, to?: string): boolean {
1316
+ const staged = this.tracker.revertOf(sessionID)
1317
+ const messageID = staged?.messageID ?? to
1318
+ if (!messageID) return false
1319
+ if (!staged && this.tracker.committedOf(sessionID)?.messageID === messageID) return false
1320
+ const now = Date.now()
1321
+ return this.tracker.setCommitted(sessionID, { messageID, ...(staged?.at === undefined ? {} : { from: staged.at }), until: staged?.stagedAt ?? now, at: now })
1322
+ }
1323
+
1324
+ /**
1325
+ * A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. A staged
1326
+ * event reads the history; a later event that arrives meanwhile (a prompt committing it at once) wins, and the
1327
+ * late read only fills in the commit's window start.
1328
+ */
1259
1329
  private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1260
1330
  if (!this.tracker.has(sessionID)) return
1331
+ const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
1332
+ this.revertSeq.set(sessionID, seq)
1261
1333
  if (event.type !== "session.revert.staged") {
1262
- if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
1334
+ const data = event.data as Record<string, unknown>
1335
+ const committed = event.type === "session.revert.committed" && this.recordCommit(sessionID, typeof data.to === "string" && data.to ? data.to : undefined)
1336
+ if (committed) this.timelines.invalidate(sessionID)
1337
+ if (this.tracker.setRevert(sessionID, undefined) || committed) this.scheduleSnapshot()
1263
1338
  return
1264
1339
  }
1265
1340
  try {
1266
1341
  const raw = (event.data as Record<string, unknown>).revert
1267
- const revert = revertSummary(raw, await this.host.context(sessionID))
1342
+ const read = revertSummary(raw, await this.host.context(sessionID))
1343
+ if (this.revertSeq.get(sessionID) !== seq) {
1344
+ const commit = this.tracker.committedOf(sessionID)
1345
+ if (commit && commit.from === undefined && read?.at !== undefined && read.messageID === commit.messageID && this.tracker.setCommitted(sessionID, { ...commit, from: read.at })) this.scheduleSnapshot()
1346
+ return
1347
+ }
1348
+ const revert = read && withStagedAt(read, this.tracker.revertOf(sessionID), Date.now())
1268
1349
  if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1269
1350
  } catch (cause) {
1270
1351
  console.error("[hub] could not read a staged revert", cause)
@@ -1279,6 +1360,8 @@ export class Spoke {
1279
1360
  const synthetic = this.host.synthetic?.bind(this.host)
1280
1361
  if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1281
1362
  return this.deliveries.run(req.messageID, req.wake, async () => {
1363
+ // Defence in depth: the hub holds deliveries while it sees the revert; this process may know it first.
1364
+ if (this.tracker.revertOf(req.sessionID)) throw new Error(REVERT_STAGED)
1282
1365
  const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
1283
1366
  const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
1284
1367
  // A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
@@ -1454,7 +1537,7 @@ export class Spoke {
1454
1537
  }
1455
1538
  const host = this.host
1456
1539
  const [messages, models, mcp, diffs, branch] = await Promise.all([
1457
- optional(() => host.context(sessionID)),
1540
+ optional(() => this.shownMessages(sessionID)),
1458
1541
  optional(host.listModels && (() => host.listModels!())),
1459
1542
  optional(host.mcpList && (() => host.mcpList!(directory))),
1460
1543
  optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
@@ -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([
@@ -92,7 +94,7 @@ export class SessionTracker {
92
94
  private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
93
95
  /** A session's pending retry (session.retry.scheduled, or a retry status), until its run moves on. */
94
96
  private readonly retries = new Map<string, RetryStatus>()
95
- /** A session's last run failure, until its next run starts. */
97
+ /** A session's last run failure, until its next run starts (or the user dismisses it). */
96
98
  private readonly errors = new Map<string, CompactRunError>()
97
99
 
98
100
  /** Applies an event seen in this process. Returns true when the snapshot may have changed. */
@@ -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)
@@ -324,6 +339,11 @@ export class SessionTracker {
324
339
  return this.errors.get(sessionID)
325
340
  }
326
341
 
342
+ /** Forgets the session's last run failure (the user dismissed it). True when there was one. */
343
+ dismissError(sessionID: string): boolean {
344
+ return this.errors.delete(sessionID)
345
+ }
346
+
327
347
  /** Sessions whose last run failed (no run started since). */
328
348
  failed(): string[] {
329
349
  return [...this.errors.keys()]
@@ -405,6 +425,7 @@ export class SessionTracker {
405
425
  ...(e.project ? { project: e.project } : {}),
406
426
  ...(e.projectName ? { projectName: e.projectName } : {}),
407
427
  ...(e.revert ? { revert: e.revert } : {}),
428
+ ...(e.committed ? { revertCommitted: e.committed } : {}),
408
429
  }))
409
430
  }
410
431
 
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
@@ -270,6 +271,8 @@ interface Entry {
270
271
  error?: string
271
272
  errorType?: string
272
273
  errorStatus?: number
274
+ /** The error the user dismissed (fingerprint), so a seed of it (a spoke that cannot forget it) stays hidden. */
275
+ dismissed?: string
273
276
  blockers: Blocker[]
274
277
  jobs: JobInfo[]
275
278
  }
@@ -312,6 +315,7 @@ export class StatusBoard {
312
315
  entry.running = true
313
316
  entry.retry = undefined
314
317
  clearError(entry)
318
+ entry.dismissed = undefined
315
319
  })
316
320
  case "session.execution.succeeded":
317
321
  case "session.execution.interrupted":
@@ -320,6 +324,7 @@ export class StatusBoard {
320
324
  return this.update(data.sessionID, (entry) => {
321
325
  endRun(entry)
322
326
  setError(entry, data.error)
327
+ entry.dismissed = undefined
323
328
  })
324
329
  // Output resumed: the retry succeeded (OpenCode sends no status for it).
325
330
  case "session.text.delta":
@@ -348,10 +353,18 @@ export class StatusBoard {
348
353
  return this.unblock(data.sessionID, data.id)
349
354
  case "session.status":
350
355
  return this.update(data.sessionID, (entry) => setStatus(entry, data.status))
356
+ // Hub-defined: the user dismissed the session's last error (it shows again on its next failure).
357
+ case "session.error.dismissed": {
358
+ const entry = typeof data.sessionID === "string" ? this.sessions.get(data.sessionID) : undefined
359
+ if (!entry?.error) return false
360
+ entry.dismissed = fingerprint(entry)
361
+ clearError(entry)
362
+ return true
363
+ }
351
364
  case "session.seed":
352
365
  return this.update(data.sessionID, (entry) => {
353
366
  setStatus(entry, data.status)
354
- if (typeof data.error?.message === "string" && data.error.message) setError(entry, data.error)
367
+ if (typeof data.error?.message === "string" && data.error.message && fingerprint(errorFields(data.error)) !== entry.dismissed) setError(entry, data.error)
355
368
  entry.jobs = entry.jobs.filter((job) => job.status !== "running")
356
369
  for (const job of Array.isArray(data.jobs) ? data.jobs : []) if (isJob(job)) upsertJob(entry, job)
357
370
  })
@@ -476,17 +489,31 @@ function setStatus(entry: Entry, status: any): void {
476
489
  ...(errorStatus !== undefined ? { errorStatus } : {}),
477
490
  }
478
491
  } else entry.retry = undefined
479
- if (type === "busy") clearError(entry)
492
+ if (type === "busy") {
493
+ clearError(entry)
494
+ entry.dismissed = undefined
495
+ }
480
496
  }
481
497
 
482
498
  const ERROR_TYPE_MAX = 100
483
499
  const ERROR_MESSAGE_MAX = 1000
484
500
 
485
501
  /** A failed run's error: its message, type and status. */
502
+ function errorFields(error: any): Pick<Entry, "error" | "errorType" | "errorStatus"> {
503
+ return {
504
+ error: typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed",
505
+ errorType: text(error?.type, ERROR_TYPE_MAX),
506
+ errorStatus: num(error?.status),
507
+ }
508
+ }
509
+
486
510
  function setError(entry: Entry, error: any): void {
487
- entry.error = typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed"
488
- entry.errorType = text(error?.type, ERROR_TYPE_MAX)
489
- entry.errorStatus = num(error?.status)
511
+ Object.assign(entry, errorFields(error))
512
+ }
513
+
514
+ /** An error as setError keeps it (message, type, status), to recognise a dismissed one when seeded again. */
515
+ function fingerprint(entry: Pick<Entry, "error" | "errorType" | "errorStatus">): string {
516
+ return JSON.stringify([entry.error, entry.errorType ?? null, entry.errorStatus ?? null])
490
517
  }
491
518
 
492
519
  function clearError(entry: Entry): void {
@@ -1 +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-lIOfSYck.js";import{t as l}from"./ArtifactWorkbench-NoFC5Sb2.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};
1
+ import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{L as o,R as s,z as c}from"./index-6mdQ7eUY.js";import{t as l}from"./ArtifactWorkbench-CkmWnP6L.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};
@@ -1 +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-NoFC5Sb2.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};
1
+ import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{t as o}from"./ArtifactWorkbench-CkmWnP6L.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};