@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.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- 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/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- 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/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- 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 +90 -7
- package/packages/protocol/src/launch.ts +14 -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/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- 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 +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
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"
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
160
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
|
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
|
|
1212
|
-
if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
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([
|
|
@@ -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
|
-
|
|
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};
|