@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.
- package/CHANGELOG.md +193 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +116 -53
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +92 -7
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +14 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +134 -29
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +91 -6
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +8 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +96 -6
- package/src/spoke/spoke.ts +119 -36
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +23 -2
- package/src/status.ts +33 -6
- package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
- package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-lIOfSYck.js +0 -34
package/src/spoke/spoke.ts
CHANGED
|
@@ -33,12 +33,12 @@ import {
|
|
|
33
33
|
type TurnResult,
|
|
34
34
|
} from "../protocol"
|
|
35
35
|
import { extractReply } from "../reply"
|
|
36
|
-
import {
|
|
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
|
-
/**
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
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
|
|
1218
|
-
if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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))),
|
package/src/spoke/timeline.ts
CHANGED
|
@@ -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
|
}
|
package/src/spoke/tracker.ts
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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-
|
|
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-
|
|
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};
|