@huynle/hub 0.8.1 → 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 +113 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +87 -52
- 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 +83 -6
- 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 +7 -0
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/http.ts +50 -21
- package/src/central/registry.ts +23 -3
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -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 +76 -6
- package/src/spoke/spoke.ts +116 -36
- 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-mNzjlP_U.js → ArtifactPage-R0ZTpHca.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-C9KrBna8.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-sP2mZccC.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-BXbJvTUS.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DhVVcAep.js → ProfilesDestination-D7XyLTJ7.js} +1 -1
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-Dgxt8UI3.js} +1 -1
- package/web/dist/assets/{Watchtower-DxaJYQp_.js → Watchtower-DJHx6cWw.js} +2 -2
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-OgQWPrSY.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-JUb6OeI3.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/index.html +1 -1
- package/web/dist/assets/index-lIOfSYck.js +0 -34
package/src/spoke/locations.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface PermissionLocation {
|
|
|
11
11
|
/** `message`: a reject's reason (OpenCode's reply message). */
|
|
12
12
|
reply(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string }): Promise<unknown>
|
|
13
13
|
vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
|
|
14
|
+
names?(): Promise<LocalNames>
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -73,3 +74,82 @@ export function vcsReader(locations: () => readonly PermissionLocation[], server
|
|
|
73
74
|
},
|
|
74
75
|
}
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/** A directory's local skill and command names (a hub profile's of the same name give way to them). */
|
|
80
|
+
export interface LocalNames {
|
|
81
|
+
readonly skills: readonly string[]
|
|
82
|
+
readonly commands: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A directory's local names; undefined: nothing could read that directory's. */
|
|
86
|
+
export type NamesSource = (directory: string) => Promise<LocalNames | undefined>
|
|
87
|
+
|
|
88
|
+
/** How long two reads of a directory's names must agree before they count (OpenCode loads them one source at a time). */
|
|
89
|
+
export const NAMES_SETTLE_MS = 500
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a directory's local skill and command names like vcsReader reads its VCS state: through the plugin
|
|
93
|
+
* instance whose location is that directory, else the own server's API (which honours the location), else not
|
|
94
|
+
* at all (undefined). OpenCode's plugin skill.list / command.list answer for the instance's own location whatever
|
|
95
|
+
* location is passed, so the first instance would report another directory's names. A location OpenCode is still
|
|
96
|
+
* loading lists nothing, then its built-ins, then its own commands and skills one source at a time: the names
|
|
97
|
+
* count once they list a command (OpenCode always has built-in ones) and two reads `settleMs` apart agree. A read
|
|
98
|
+
* that fails, or does not settle within `timeoutMs`, is unknown.
|
|
99
|
+
*/
|
|
100
|
+
export function namesReader(
|
|
101
|
+
locations: () => readonly PermissionLocation[],
|
|
102
|
+
server: () => Promise<NamesSource | undefined>,
|
|
103
|
+
timeoutMs: number = VCS_READ_TIMEOUT_MS,
|
|
104
|
+
settleMs: number = NAMES_SETTLE_MS,
|
|
105
|
+
): NamesSource {
|
|
106
|
+
return (directory) => {
|
|
107
|
+
const local = locations().find((l) => l.directory === directory && l.names)
|
|
108
|
+
let done = false
|
|
109
|
+
const settled = async (read: NamesSource): Promise<LocalNames | undefined> => {
|
|
110
|
+
let previous = await read(directory)
|
|
111
|
+
while (!done) {
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs))
|
|
113
|
+
const next = await read(directory)
|
|
114
|
+
if (next === undefined) return undefined
|
|
115
|
+
if (next.commands.length && previous && sameNames(previous, next)) return next
|
|
116
|
+
previous = next
|
|
117
|
+
}
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
const reading = local?.names ? settled(() => local.names!()) : server().then((read) => (read ? settled(read) : undefined))
|
|
121
|
+
return within(reading, timeoutMs).finally(() => (done = true))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameNames(a: LocalNames, b: LocalNames): boolean {
|
|
126
|
+
const same = (x: readonly string[], y: readonly string[]) => x.length === y.length && x.every((name, i) => name === y[i])
|
|
127
|
+
return same(a.skills, b.skills) && same(a.commands, b.commands)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The `name` of each item of a plugin or HTTP API list (bare, or wrapped as { location, data }). */
|
|
131
|
+
export function listedNames(value: unknown): string[] {
|
|
132
|
+
const v = value as any
|
|
133
|
+
const list = Array.isArray(v) ? v : Array.isArray(v?.data) ? v.data : []
|
|
134
|
+
return list.flatMap((item: any) => (typeof item?.name === "string" ? [item.name] : []))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ListApi {
|
|
138
|
+
list(input: { location: { directory: string } }): Promise<unknown>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A directory's names over the own server's API (skill.list / command.list honour the location there); an answer for another directory is unknown. */
|
|
142
|
+
export function serverNames(client: { skill: ListApi; command: ListApi }): NamesSource {
|
|
143
|
+
return async (directory) => {
|
|
144
|
+
const location = { directory }
|
|
145
|
+
const [skills, commands] = await Promise.all([client.skill.list({ location }), client.command.list({ location })])
|
|
146
|
+
if ([skills, commands].some((answer) => answeredFor(answer) !== undefined && answeredFor(answer) !== directory)) return undefined
|
|
147
|
+
return { skills: listedNames(skills), commands: listedNames(commands) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The directory a wrapped { location, data } answer is for (undefined: a bare answer). */
|
|
152
|
+
function answeredFor(answer: unknown): string | undefined {
|
|
153
|
+
const directory = (answer as any)?.location?.directory
|
|
154
|
+
return typeof directory === "string" ? directory : undefined
|
|
155
|
+
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// - Skills and commands: listed in that system part and served by the `hub_profile` tool (registered once per
|
|
8
8
|
// process like the `hub` tool; the context hook hides it from sessions without a profile, and it answers only
|
|
9
9
|
// the calling session's layer). OpenCode's skill/command registries are location-wide, so they are not used.
|
|
10
|
-
// Same-named local skills/commands win (partial)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Same-named local skills/commands win (partial); their names are read in the session's own folder, and when they
|
|
11
|
+
// cannot be read the profile's skills and commands are not offered (partial with a note; the instructions apply).
|
|
12
|
+
// Commands that reference `{{secret:NAME}}` are skipped: a rendered command becomes a persisted prompt or tool
|
|
13
|
+
// result in OpenCode, and resolved secrets must never be written (renderAtInvocation false). The model preference
|
|
14
|
+
// is ignored (the user's model is never switched).
|
|
13
15
|
// - Next-turn boundary: an idle session's layer is replaced at once (its next turn reads it). For a busy session
|
|
14
16
|
// the new layer is staged, the running turn keeps the previous one, and the answer waits for the turn to end
|
|
15
17
|
// (Spoke.observe calls settle; a poll backs it up). The answer echoes exactly the version that became
|
|
@@ -41,11 +43,19 @@ export const PROFILE_TOOL_INPUT = {
|
|
|
41
43
|
additionalProperties: false,
|
|
42
44
|
} as const
|
|
43
45
|
|
|
46
|
+
/** The note of an apply whose session's local skill and command names could not be read. */
|
|
47
|
+
const UNKNOWN_NAMES_NOTE = "skills and commands not offered: the session's local skills and commands could not be read"
|
|
48
|
+
/** The planner's notes for skills/commands this adapter did not offer (replaced by UNKNOWN_NAMES_NOTE). */
|
|
49
|
+
const ADAPTER_UNSUPPORTED = /^(skills|commands) not supported by this harness adapter$/
|
|
50
|
+
|
|
44
51
|
export interface OpenCodeProfilesOptions {
|
|
45
52
|
/** The session has a turn running (OpenCode busy, or a hub turn in progress). */
|
|
46
53
|
readonly isBusy: (sessionID: string) => boolean
|
|
47
|
-
/**
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The session's local skill and command names, read in its own folder; absent: skills and commands are not
|
|
56
|
+
* applied. Undefined or a failure: unknown, so the instructions apply and the skills and commands are not offered.
|
|
57
|
+
*/
|
|
58
|
+
readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames | undefined>
|
|
49
59
|
/** The hub_profile tool is available in this OpenCode. */
|
|
50
60
|
readonly tool: () => boolean
|
|
51
61
|
/** How long a busy session's answer waits for the boundary (default 120 s). */
|
|
@@ -177,20 +187,20 @@ export class OpenCodeProfiles {
|
|
|
177
187
|
this.supersede(sessionID, req)
|
|
178
188
|
return { result: current.result }
|
|
179
189
|
}
|
|
190
|
+
// Local names are read only when there is something they could shadow. Unknown names (unreadable in the
|
|
191
|
+
// session's own folder) never reject the apply: the skills and commands are just not offered.
|
|
192
|
+
const tool = this.options.tool()
|
|
193
|
+
const wantsBindings = tool && (req.content.skills.length > 0 || req.content.commands.length > 0)
|
|
180
194
|
let local: ProfileLocalNames | undefined
|
|
181
|
-
if (this.options.localNames)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
} catch {
|
|
185
|
-
return { result: profileApplyResult(req, "rejected", ["could not read the session's local skills and commands (they take precedence); nothing applied"]) }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
const bindings = this.options.tool() && local !== undefined
|
|
195
|
+
if (wantsBindings && this.options.localNames) local = await this.options.localNames(sessionID).catch(() => undefined)
|
|
196
|
+
const unknown = wantsBindings && this.options.localNames !== undefined && local === undefined
|
|
197
|
+
const bindings = tool && local !== undefined
|
|
189
198
|
const plan = planProfileApplication(req, {
|
|
190
199
|
capabilities: { instructions: true, skills: bindings, commands: bindings, model: false, live: true, renderAtInvocation: false },
|
|
191
200
|
...(local ? { local } : {}),
|
|
192
201
|
})
|
|
193
|
-
const
|
|
202
|
+
const notes = unknown ? [UNKNOWN_NAMES_NOTE, ...plan.notes.filter((note) => !ADAPTER_UNSUPPORTED.test(note))] : plan.notes
|
|
203
|
+
const result = profileApplyResult(req, plan.status, notes)
|
|
194
204
|
if (plan.status === "unsupported") return { result }
|
|
195
205
|
const layer: Layer = { result, ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}), skills: plan.skills, commands: plan.commands }
|
|
196
206
|
this.supersede(sessionID, req)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// CON-04 session contexts (spoke feature "sessionContext"; contract packages/protocol/src/concierge.ts). The hub pushes
|
|
2
2
|
// a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
|
|
3
3
|
//
|
|
4
|
-
// - parts: appended by the plugin's context hook after the profile parts
|
|
5
|
-
//
|
|
4
|
+
// - parts: instructions then notes are appended by the plugin's context hook after the profile parts; the board goes
|
|
5
|
+
// after the conversation as a system message (V-01: it changes between turns, and OpenCode sends tools, system,
|
|
6
|
+
// then messages, so a board in the system prompt made every push re-write the cached conversation; see
|
|
7
|
+
// applySessionContext). The whole set replaces the previous one.
|
|
8
|
+
// - The kept tools are put in name order (the request lists them in the hook's record order).
|
|
6
9
|
// - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
|
|
7
10
|
// future tools included: fail closed). tools.deny: globs of tool names deleted as well.
|
|
8
11
|
// - Revisions: an older revision than the effective one is refused ("stale"); the same one is answered again.
|
|
@@ -18,15 +21,76 @@
|
|
|
18
21
|
// another agent.
|
|
19
22
|
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
20
23
|
import path from "node:path"
|
|
21
|
-
import { CONCIERGE_TOOLS,
|
|
24
|
+
import { CONCIERGE_TOOLS, type SessionContextKey, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
|
|
22
25
|
|
|
23
26
|
interface Context {
|
|
24
27
|
readonly revision: number
|
|
28
|
+
/** The stable parts (instructions, notes): the end of the system prompt. */
|
|
25
29
|
readonly parts: readonly string[]
|
|
30
|
+
/** The changing parts (board): after the conversation. */
|
|
31
|
+
readonly tail: readonly string[]
|
|
26
32
|
readonly deny: readonly string[]
|
|
27
33
|
readonly allow?: ReadonlySet<string>
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/** Parts at the end of the system prompt (stable between turns), in this order. */
|
|
37
|
+
const SYSTEM_KEYS: readonly SessionContextKey[] = ["instructions", "notes"]
|
|
38
|
+
/** Parts after the conversation (they change between turns). */
|
|
39
|
+
const TAIL_KEYS: readonly SessionContextKey[] = ["board"]
|
|
40
|
+
const EPHEMERAL = { type: "ephemeral" } as const
|
|
41
|
+
|
|
42
|
+
interface HookPart {
|
|
43
|
+
type: string
|
|
44
|
+
text?: string
|
|
45
|
+
cache?: unknown
|
|
46
|
+
}
|
|
47
|
+
interface HookMessage {
|
|
48
|
+
role: string
|
|
49
|
+
content: HookPart[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The tools in name order (OpenCode sends them in the record's order: the request's prefix must not move). */
|
|
53
|
+
export function sortTools(tools: Record<string, unknown>): void {
|
|
54
|
+
const entries = Object.entries(tools).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
55
|
+
for (const [name] of entries) delete tools[name]
|
|
56
|
+
for (const [name, tool] of entries) tools[name] = tool
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An OpenCode effort message (a system message carrying only the reasoning effort). */
|
|
60
|
+
const isEffort = (m: HookMessage) => m.role === "system" && m.content.length === 1 && m.content[0]?.type === "effort"
|
|
61
|
+
|
|
62
|
+
/** The index of the last item matching (-1: none). */
|
|
63
|
+
const lastIndex = <T>(items: readonly T[], match: (item: T) => boolean) => {
|
|
64
|
+
for (let i = items.length - 1; i >= 0; i--) if (match(items[i]!)) return i
|
|
65
|
+
return -1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A cache hint on the message's last text part (else its last part that is not compaction or effort). */
|
|
69
|
+
function hint(message: HookMessage): void {
|
|
70
|
+
const text = lastIndex(message.content, (p) => p.type === "text")
|
|
71
|
+
const index = text >= 0 ? text : lastIndex(message.content, (p) => p.type !== "compaction" && p.type !== "effort")
|
|
72
|
+
const part = message.content[index]
|
|
73
|
+
if (part && part.cache === undefined) message.content[index] = { ...part, cache: EPHEMERAL }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* (V-01) Adds a session context to a context hook's request so the provider's prompt cache survives a board change.
|
|
78
|
+
* OpenCode sends tools, the system parts, then the messages; it puts cache breakpoints on the last tool, the first and
|
|
79
|
+
* last system part and the last message (4 at most). The stable parts end the system prompt (the last one carries a
|
|
80
|
+
* hint); the board follows the conversation as one system message (with a hint), and the conversation's last message
|
|
81
|
+
* gets a hint, so the next turn reads everything up to it from the cache whatever the board says. Without a board
|
|
82
|
+
* nothing is added after the conversation; a request without messages gets the board in its system prompt.
|
|
83
|
+
*/
|
|
84
|
+
export function applySessionContext(event: { system: HookPart[]; messages?: HookMessage[] }, system: readonly string[], tail: readonly string[]): void {
|
|
85
|
+
const messages = Array.isArray(event.messages) ? event.messages : undefined
|
|
86
|
+
const ending = messages ? system : [...system, ...tail]
|
|
87
|
+
ending.forEach((text, i) => event.system.push(i === ending.length - 1 ? { type: "text", text, cache: EPHEMERAL } : { type: "text", text }))
|
|
88
|
+
if (!messages || !tail.length) return
|
|
89
|
+
const last = messages[lastIndex(messages, (m) => !isEffort(m))]
|
|
90
|
+
if (last) hint(last)
|
|
91
|
+
messages.push({ role: "system", content: [{ type: "text", text: tail.join("\n\n"), cache: EPHEMERAL }] })
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
|
|
31
95
|
const LOCKS_MAX = 1000
|
|
32
96
|
const LOCKED_TOOLS: ReadonlySet<string> = new Set(CONCIERGE_TOOLS)
|
|
@@ -191,9 +255,9 @@ export class SessionContexts {
|
|
|
191
255
|
this.cleared.delete(req.sessionID)
|
|
192
256
|
if (req.lockAgent) this.lock(req.sessionID)
|
|
193
257
|
const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
|
|
194
|
-
const
|
|
258
|
+
const pick = (keys: readonly SessionContextKey[]) => keys.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
|
|
195
259
|
const allow = req.tools?.allow
|
|
196
|
-
this.contexts.set(req.sessionID, { revision: req.revision, parts, deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
|
|
260
|
+
this.contexts.set(req.sessionID, { revision: req.revision, parts: pick(SYSTEM_KEYS), tail: pick(TAIL_KEYS), deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
|
|
197
261
|
this.bound(this.contexts)
|
|
198
262
|
return { revision: req.revision }
|
|
199
263
|
}
|
|
@@ -207,11 +271,16 @@ export class SessionContexts {
|
|
|
207
271
|
while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
|
|
208
272
|
}
|
|
209
273
|
|
|
210
|
-
/** The session's parts for its system prompt (none without a context). */
|
|
274
|
+
/** The session's stable parts for the end of its system prompt: instructions, notes (none without a context). */
|
|
211
275
|
systemParts(sessionID: string): string[] {
|
|
212
276
|
return [...(this.contexts.get(sessionID)?.parts ?? [])]
|
|
213
277
|
}
|
|
214
278
|
|
|
279
|
+
/** The session's changing parts, sent after its conversation: the board (none without a context). */
|
|
280
|
+
tailParts(sessionID: string): string[] {
|
|
281
|
+
return [...(this.contexts.get(sessionID)?.tail ?? [])]
|
|
282
|
+
}
|
|
283
|
+
|
|
215
284
|
/** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
|
|
216
285
|
locked(sessionID: string): boolean {
|
|
217
286
|
if (!this.locks.has(sessionID)) this.refresh()
|
|
@@ -234,6 +303,7 @@ export class SessionContexts {
|
|
|
234
303
|
filterTools(sessionID: string, tools: Record<string, unknown>): void {
|
|
235
304
|
if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
|
|
236
305
|
for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
|
|
306
|
+
sortTools(tools)
|
|
237
307
|
}
|
|
238
308
|
|
|
239
309
|
/** Records the agent a locked session runs on; true when it changed (a new report is due). */
|
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 {
|
|
@@ -1152,27 +1162,45 @@ export class Spoke {
|
|
|
1152
1162
|
// Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
|
|
1153
1163
|
if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
|
|
1154
1164
|
return this.profiles.handle(req)
|
|
1165
|
+
// Fork and revert: OpenCode's busy and not-found failures answer coded refusals (Res.code).
|
|
1155
1166
|
case "fork":
|
|
1156
|
-
return this.fork(req.sessionID, req.messageID)
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
+
})
|
|
1163
1174
|
case "revert":
|
|
1164
|
-
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
|
+
})
|
|
1165
1178
|
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
1179
|
case "revertCommit":
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1180
|
+
return this.revertEnd(req.sessionID, req.op).catch((cause) => {
|
|
1181
|
+
throw opError(cause, REVERT_END_BUSY)
|
|
1182
|
+
})
|
|
1173
1183
|
}
|
|
1174
1184
|
}
|
|
1175
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
|
+
|
|
1176
1204
|
private canRevert(): boolean {
|
|
1177
1205
|
return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
|
|
1178
1206
|
}
|
|
@@ -1186,7 +1214,7 @@ export class Spoke {
|
|
|
1186
1214
|
if (!fork) throw new Error(CANNOT_FORK)
|
|
1187
1215
|
const messages = await this.host.context(sessionID)
|
|
1188
1216
|
const index = userIndex(messages, messageID)
|
|
1189
|
-
if (index < 0) throw
|
|
1217
|
+
if (index < 0) throw notAUserMessage()
|
|
1190
1218
|
const session = await fork({ sessionID, before: messageID })
|
|
1191
1219
|
if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
|
|
1192
1220
|
const title = typeof session.title === "string" ? session.title : ""
|
|
@@ -1203,9 +1231,34 @@ export class Spoke {
|
|
|
1203
1231
|
})
|
|
1204
1232
|
if (this.config.mode === "session") this.setSession(session.id, true)
|
|
1205
1233
|
this.sendSnapshot()
|
|
1234
|
+
if (copiesHubIdentity(messages, index)) await this.forkNote(session.id, forkIdentityNote(sessionID, source?.title))
|
|
1206
1235
|
return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
|
|
1207
1236
|
}
|
|
1208
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
|
+
|
|
1209
1262
|
/**
|
|
1210
1263
|
* Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
|
|
1211
1264
|
* `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
|
|
@@ -1214,16 +1267,10 @@ export class Spoke {
|
|
|
1214
1267
|
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1215
1268
|
const messages = await this.host.context(sessionID)
|
|
1216
1269
|
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)!
|
|
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())
|
|
1227
1274
|
this.timelines.invalidate(sessionID)
|
|
1228
1275
|
// Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
|
|
1229
1276
|
this.tracker.setRevert(sessionID, revert)
|
|
@@ -1250,21 +1297,52 @@ export class Spoke {
|
|
|
1250
1297
|
])
|
|
1251
1298
|
if (!this.canRevert()) return messages
|
|
1252
1299
|
const raw = session ? (session as { revert?: unknown }).revert : undefined
|
|
1253
|
-
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
|
|
1254
1303
|
if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1255
1304
|
return visibleMessages(messages, revert?.messageID)
|
|
1256
1305
|
}
|
|
1257
1306
|
|
|
1258
|
-
/**
|
|
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
|
+
*/
|
|
1259
1326
|
private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
|
|
1260
1327
|
if (!this.tracker.has(sessionID)) return
|
|
1328
|
+
const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
|
|
1329
|
+
this.revertSeq.set(sessionID, seq)
|
|
1261
1330
|
if (event.type !== "session.revert.staged") {
|
|
1262
|
-
|
|
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()
|
|
1263
1335
|
return
|
|
1264
1336
|
}
|
|
1265
1337
|
try {
|
|
1266
1338
|
const raw = (event.data as Record<string, unknown>).revert
|
|
1267
|
-
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())
|
|
1268
1346
|
if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1269
1347
|
} catch (cause) {
|
|
1270
1348
|
console.error("[hub] could not read a staged revert", cause)
|
|
@@ -1279,6 +1357,8 @@ export class Spoke {
|
|
|
1279
1357
|
const synthetic = this.host.synthetic?.bind(this.host)
|
|
1280
1358
|
if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
|
|
1281
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)
|
|
1282
1362
|
const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
|
|
1283
1363
|
const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
|
|
1284
1364
|
// A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
|
|
@@ -1454,7 +1534,7 @@ export class Spoke {
|
|
|
1454
1534
|
}
|
|
1455
1535
|
const host = this.host
|
|
1456
1536
|
const [messages, models, mcp, diffs, branch] = await Promise.all([
|
|
1457
|
-
optional(() =>
|
|
1537
|
+
optional(() => this.shownMessages(sessionID)),
|
|
1458
1538
|
optional(host.listModels && (() => host.listModels!())),
|
|
1459
1539
|
optional(host.mcpList && (() => host.mcpList!(directory))),
|
|
1460
1540
|
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
|
}
|