@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// REC-01 registry taps (contract: Event schema → Hub): turn.started / first.token / turn.ended of the recorded root
|
|
2
|
+
// session, tool rows (summary only, never the output), say.sent per spoken sentence, reply.full, utterance.delivered.
|
|
3
|
+
// The registry calls these only from the session owner's connection; everything here is synchronous and cheap, the
|
|
4
|
+
// writes happen in the recording service (asynchronous, best effort).
|
|
5
|
+
import type { HubEventKind } from "../../protocol"
|
|
6
|
+
import type { RecordingTap } from "../registry"
|
|
7
|
+
|
|
8
|
+
export interface TapSink {
|
|
9
|
+
/** Records a hub event into every active recording of (userID, sessionID); a no-op without one. */
|
|
10
|
+
hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
|
|
11
|
+
/** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
|
|
12
|
+
mayRecord(userID: string): boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
|
|
16
|
+
"session.execution.succeeded": "succeeded",
|
|
17
|
+
"session.execution.failed": "failed",
|
|
18
|
+
"session.execution.interrupted": "interrupted",
|
|
19
|
+
}
|
|
20
|
+
const TOOL_STATES: Record<string, "called" | "success" | "failed"> = {
|
|
21
|
+
"session.tool.called": "called",
|
|
22
|
+
"session.tool.success": "success",
|
|
23
|
+
"session.tool.failed": "failed",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const remember = <K, V>(map: Map<K, V>, key: K, value: V, max = 1000) => {
|
|
27
|
+
map.delete(key)
|
|
28
|
+
map.set(key, value)
|
|
29
|
+
if (map.size > max) map.delete(map.keys().next().value as K)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RegistryTaps implements RecordingTap {
|
|
33
|
+
/** Root sessions whose turn started and whose first text delta is not seen yet. */
|
|
34
|
+
private readonly firstToken = new Set<string>()
|
|
35
|
+
/** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
|
|
36
|
+
private readonly tools = new Map<string, string>()
|
|
37
|
+
|
|
38
|
+
constructor(private readonly sink: TapSink) {}
|
|
39
|
+
|
|
40
|
+
wants(tenant: string, sessionID: string, type: string): boolean {
|
|
41
|
+
if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
|
|
42
|
+
if (type === "session.execution.started") return true
|
|
43
|
+
return this.sink.mayRecord(tenant)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
47
|
+
if (type === "session.execution.started") {
|
|
48
|
+
this.firstToken.add(sessionID)
|
|
49
|
+
if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
|
|
50
|
+
this.sink.hub(tenant, sessionID, "turn.started", {})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const outcome = OUTCOMES[type]
|
|
54
|
+
if (outcome) {
|
|
55
|
+
this.firstToken.delete(sessionID)
|
|
56
|
+
this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (type === "session.text.delta") {
|
|
60
|
+
if (!this.firstToken.delete(sessionID)) return
|
|
61
|
+
const { assistantMessageID: messageID, ordinal } = data
|
|
62
|
+
this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const callID = typeof data.id === "string" ? data.id : undefined
|
|
66
|
+
if (type === "session.tool.input.started") {
|
|
67
|
+
if (callID && typeof data.name === "string") remember(this.tools, callID, data.name.slice(0, 120))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const state = TOOL_STATES[type]
|
|
71
|
+
if (!state || !callID) return
|
|
72
|
+
const tool = this.tools.get(callID)
|
|
73
|
+
if (state !== "called") this.tools.delete(callID)
|
|
74
|
+
// The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
|
|
75
|
+
const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
|
|
76
|
+
this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void {
|
|
80
|
+
this.sink.hub(tenant, sessionID, "say.sent", { messageID, ordinal, seq, text })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void {
|
|
84
|
+
if (!text.trim()) return
|
|
85
|
+
this.sink.hub(tenant, sessionID, "reply.full", { messageID, ordinal, text })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
heard(tenant: string, sessionID: string, inboxID: string): void {
|
|
89
|
+
this.sink.hub(tenant, sessionID, "utterance.delivered", { inboxID })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A TTS ref header "<messageID>:<ordinal>:<seq>:<i>" (seq may be empty); undefined when malformed. */
|
|
94
|
+
export function parseTtsRef(header: string | null): { messageID: string; ordinal: number; seq?: number; i: number } | undefined {
|
|
95
|
+
if (!header || header.length > 400) return undefined
|
|
96
|
+
const parts = header.split(":")
|
|
97
|
+
if (parts.length < 4) return undefined
|
|
98
|
+
const i = Number(parts.pop())
|
|
99
|
+
const seqText = parts.pop()!
|
|
100
|
+
const ordinal = Number(parts.pop())
|
|
101
|
+
const messageID = parts.join(":")
|
|
102
|
+
const seq = seqText === "" ? undefined : Number(seqText)
|
|
103
|
+
if (!messageID || !Number.isSafeInteger(i) || !Number.isSafeInteger(ordinal) || (seq !== undefined && !Number.isSafeInteger(seq))) return undefined
|
|
104
|
+
return { messageID, ordinal, ...(seq !== undefined ? { seq } : {}), i }
|
|
105
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// REC-01 streaming zip writer for recording exports (contract: Export response): the STORE method (no
|
|
2
|
+
// compression; WAV hardly compresses), CRC-32 (Bun.hash.crc32), data descriptors (flag bit 3) so nothing is
|
|
3
|
+
// buffered per entry, UTF-8 names (flag bit 11), no ZIP64 (RECORDING_LIMITS.userBytes keeps an export far below 4 GiB).
|
|
4
|
+
|
|
5
|
+
export interface ZipEntry {
|
|
6
|
+
/** Path inside the archive ("manifest.json", "<id>/audio/000001.wav"). */
|
|
7
|
+
readonly name: string
|
|
8
|
+
/** The entry's bytes, read once when the writer reaches it; undefined writes an empty entry. */
|
|
9
|
+
body(): Promise<Uint8Array | ReadableStream<Uint8Array> | undefined>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FLAGS = 0x0008 | 0x0800
|
|
13
|
+
/** 1980-01-01 00:00 in DOS time (the exports carry their own timestamps in the manifest). */
|
|
14
|
+
const DOS_TIME = 0
|
|
15
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1
|
|
16
|
+
|
|
17
|
+
interface Written {
|
|
18
|
+
readonly name: Uint8Array
|
|
19
|
+
readonly offset: number
|
|
20
|
+
readonly crc: number
|
|
21
|
+
readonly size: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const le = (size: number) => {
|
|
25
|
+
const bytes = new Uint8Array(size)
|
|
26
|
+
return { bytes, view: new DataView(bytes.buffer) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function localHeader(name: Uint8Array): Uint8Array {
|
|
30
|
+
const { bytes, view } = le(30 + name.length)
|
|
31
|
+
view.setUint32(0, 0x04034b50, true)
|
|
32
|
+
view.setUint16(4, 20, true)
|
|
33
|
+
view.setUint16(6, FLAGS, true)
|
|
34
|
+
view.setUint16(8, 0, true)
|
|
35
|
+
view.setUint16(10, DOS_TIME, true)
|
|
36
|
+
view.setUint16(12, DOS_DATE, true)
|
|
37
|
+
// CRC and sizes follow in the data descriptor.
|
|
38
|
+
view.setUint16(26, name.length, true)
|
|
39
|
+
bytes.set(name, 30)
|
|
40
|
+
return bytes
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function descriptor(crc: number, size: number): Uint8Array {
|
|
44
|
+
const { bytes, view } = le(16)
|
|
45
|
+
view.setUint32(0, 0x08074b50, true)
|
|
46
|
+
view.setUint32(4, crc, true)
|
|
47
|
+
view.setUint32(8, size, true)
|
|
48
|
+
view.setUint32(12, size, true)
|
|
49
|
+
return bytes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function centralEntry(entry: Written): Uint8Array {
|
|
53
|
+
const { bytes, view } = le(46 + entry.name.length)
|
|
54
|
+
view.setUint32(0, 0x02014b50, true)
|
|
55
|
+
view.setUint16(4, 20, true)
|
|
56
|
+
view.setUint16(6, 20, true)
|
|
57
|
+
view.setUint16(8, FLAGS, true)
|
|
58
|
+
view.setUint16(10, 0, true)
|
|
59
|
+
view.setUint16(12, DOS_TIME, true)
|
|
60
|
+
view.setUint16(14, DOS_DATE, true)
|
|
61
|
+
view.setUint32(16, entry.crc, true)
|
|
62
|
+
view.setUint32(20, entry.size, true)
|
|
63
|
+
view.setUint32(24, entry.size, true)
|
|
64
|
+
view.setUint16(28, entry.name.length, true)
|
|
65
|
+
view.setUint32(42, entry.offset, true)
|
|
66
|
+
bytes.set(entry.name, 46)
|
|
67
|
+
return bytes
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function endOfDirectory(count: number, size: number, offset: number): Uint8Array {
|
|
71
|
+
const { bytes, view } = le(22)
|
|
72
|
+
view.setUint32(0, 0x06054b50, true)
|
|
73
|
+
view.setUint16(8, count, true)
|
|
74
|
+
view.setUint16(10, count, true)
|
|
75
|
+
view.setUint32(12, size, true)
|
|
76
|
+
view.setUint32(16, offset, true)
|
|
77
|
+
return bytes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The archive of `entries`, streamed: each entry's body is read only when the reader gets to it. */
|
|
81
|
+
export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
|
|
82
|
+
async function* produce(): AsyncGenerator<Uint8Array> {
|
|
83
|
+
const written: Written[] = []
|
|
84
|
+
let offset = 0
|
|
85
|
+
for await (const entry of entries as AsyncIterable<ZipEntry>) {
|
|
86
|
+
const name = new TextEncoder().encode(entry.name)
|
|
87
|
+
const header = localHeader(name)
|
|
88
|
+
const start = offset
|
|
89
|
+
yield header
|
|
90
|
+
offset += header.length
|
|
91
|
+
let crc = 0
|
|
92
|
+
let size = 0
|
|
93
|
+
const body = await entry.body()
|
|
94
|
+
const take = (chunk: Uint8Array) => {
|
|
95
|
+
crc = Bun.hash.crc32(chunk, crc)
|
|
96
|
+
size += chunk.length
|
|
97
|
+
offset += chunk.length
|
|
98
|
+
}
|
|
99
|
+
if (body instanceof Uint8Array) {
|
|
100
|
+
take(body)
|
|
101
|
+
if (body.length) yield body
|
|
102
|
+
} else if (body) {
|
|
103
|
+
for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
|
|
104
|
+
take(chunk)
|
|
105
|
+
if (chunk.length) yield chunk
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
crc >>>= 0
|
|
109
|
+
const tail = descriptor(crc, size)
|
|
110
|
+
yield tail
|
|
111
|
+
offset += tail.length
|
|
112
|
+
written.push({ name, offset: start, crc, size })
|
|
113
|
+
}
|
|
114
|
+
const directoryStart = offset
|
|
115
|
+
let directorySize = 0
|
|
116
|
+
for (const entry of written) {
|
|
117
|
+
const bytes = centralEntry(entry)
|
|
118
|
+
directorySize += bytes.length
|
|
119
|
+
yield bytes
|
|
120
|
+
}
|
|
121
|
+
yield endOfDirectory(written.length, directorySize, directoryStart)
|
|
122
|
+
}
|
|
123
|
+
const iterator = produce()
|
|
124
|
+
return new ReadableStream<Uint8Array>({
|
|
125
|
+
async pull(controller) {
|
|
126
|
+
try {
|
|
127
|
+
const { done, value } = await iterator.next()
|
|
128
|
+
if (done) controller.close()
|
|
129
|
+
else controller.enqueue(value)
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
controller.error(cause)
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async cancel() {
|
|
135
|
+
await iterator.return(undefined)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
package/src/central/registry.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
|
-
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
14
|
+
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
15
|
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { DoneWatch } from "./done"
|
|
@@ -20,6 +20,23 @@ import type { ActivityStep, NotificationKind } from "./push"
|
|
|
20
20
|
|
|
21
21
|
export { ACTIVITY_INTERVAL_MINUTES } from "./store"
|
|
22
22
|
/** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
|
|
23
|
+
/**
|
|
24
|
+
* (REC-01) Voice recording taps: what the hub sees of a recorded root session (docs/voice-recording.md). Called
|
|
25
|
+
* only from the session owner's connection (deduped like speakDelta), never changing behaviour; the tap writes
|
|
26
|
+
* only into recordings of `tenant` (the session's owner) and answers at once (its writes are asynchronous).
|
|
27
|
+
*/
|
|
28
|
+
export interface RecordingTap {
|
|
29
|
+
/** Cheap pre-check before an event is tapped (e.g. a text delta only while a first token is awaited). */
|
|
30
|
+
wants(tenant: string, sessionID: string, type: string): boolean
|
|
31
|
+
event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void
|
|
32
|
+
sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void
|
|
33
|
+
reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void
|
|
34
|
+
heard(tenant: string, sessionID: string, inboxID: string): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Spoke events a RecordingTap sees (root sessions only). */
|
|
38
|
+
const TAPPED = new Set(["session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.tool.input.started", "session.tool.called", "session.tool.success", "session.tool.failed", "session.text.delta"])
|
|
39
|
+
|
|
23
40
|
export const LONG_TOOL_MS = 30_000
|
|
24
41
|
|
|
25
42
|
export class BusyError extends Error {
|
|
@@ -224,6 +241,8 @@ export interface SessionView extends BrowserFields {
|
|
|
224
241
|
readonly terminal?: TerminalInfo
|
|
225
242
|
/** A staged revert (feature "revert"), as the most recent report says. */
|
|
226
243
|
readonly revert?: SessionRevertInfo
|
|
244
|
+
/** The latest committed revert's removed window (feature "revert"), as the most recent report says. */
|
|
245
|
+
readonly revertCommitted?: RevertCommitted
|
|
227
246
|
/** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
|
|
228
247
|
readonly agent?: string
|
|
229
248
|
readonly contextRevision?: number
|
|
@@ -397,6 +416,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
|
397
416
|
const isOn = (s: SessionInfo) => s.exposed !== false
|
|
398
417
|
|
|
399
418
|
export class Registry {
|
|
419
|
+
/** (REC-01) Voice recording taps; optional, best effort. */
|
|
420
|
+
recordingTap?: RecordingTap
|
|
421
|
+
|
|
400
422
|
private readonly conns = new Map<string, Conn>()
|
|
401
423
|
private readonly boards = new Map<string, StatusBoard>()
|
|
402
424
|
/**
|
|
@@ -553,6 +575,12 @@ export class Registry {
|
|
|
553
575
|
return conn && this.tenant(conn)
|
|
554
576
|
}
|
|
555
577
|
|
|
578
|
+
/** The committed revert windows the connection reports for its own tenant's sessions (feature "revert"). */
|
|
579
|
+
revertCommits(connID: string): Array<{ sessionID: string; committed: RevertCommitted }> {
|
|
580
|
+
const conn = this.conn(connID)
|
|
581
|
+
return conn ? this.visible(conn).flatMap((s) => (s.revertCommitted ? [{ sessionID: s.id, committed: s.revertCommitted }] : [])) : []
|
|
582
|
+
}
|
|
583
|
+
|
|
556
584
|
/** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
|
|
557
585
|
reportedSessions(connID: string): string[] {
|
|
558
586
|
const conn = this.conn(connID)
|
|
@@ -736,6 +764,7 @@ export class Registry {
|
|
|
736
764
|
}
|
|
737
765
|
|
|
738
766
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
767
|
+
if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
|
|
739
768
|
const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
|
|
740
769
|
this.observeBoards(event, tenant)
|
|
741
770
|
if (sessionID) this.activitySteps(event.type, sessionID, data, before)
|
|
@@ -761,6 +790,31 @@ export class Registry {
|
|
|
761
790
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
762
791
|
}
|
|
763
792
|
|
|
793
|
+
/** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
|
|
794
|
+
private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
795
|
+
const tap = this.recordingTap
|
|
796
|
+
if (!tap || tenant === undefined) return
|
|
797
|
+
try {
|
|
798
|
+
if (!tap.wants(tenant, sessionID, type) || this.rootOf(sessionID) !== sessionID || this.owner(sessionID) !== connID) return
|
|
799
|
+
tap.event(tenant, sessionID, type, data)
|
|
800
|
+
} catch (cause) {
|
|
801
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** (REC-01) A tap call that never throws into the registry. */
|
|
806
|
+
private tapSafely(call: (tap: RecordingTap, tenant: string) => void, sessionID: string): void {
|
|
807
|
+
const tap = this.recordingTap
|
|
808
|
+
if (!tap) return
|
|
809
|
+
const tenant = this.sessionTenant(sessionID)
|
|
810
|
+
if (tenant === undefined) return
|
|
811
|
+
try {
|
|
812
|
+
call(tap, tenant)
|
|
813
|
+
} catch (cause) {
|
|
814
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
764
818
|
/** Records when a session's run started (kept by the first process to report it while it runs). */
|
|
765
819
|
private trackRun(type: string, sessionID: string): void {
|
|
766
820
|
if (type === "session.execution.started") {
|
|
@@ -808,8 +862,10 @@ export class Registry {
|
|
|
808
862
|
|
|
809
863
|
/** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
|
|
810
864
|
trackUtterance(sessionID: string, inboxID: string): void {
|
|
811
|
-
if (this.deliveries.delete(inboxID))
|
|
812
|
-
|
|
865
|
+
if (this.deliveries.delete(inboxID)) {
|
|
866
|
+
this.emit({ type: "heard", sessionID, inboxID })
|
|
867
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
868
|
+
} else remember(this.utterances, inboxID, sessionID)
|
|
813
869
|
}
|
|
814
870
|
|
|
815
871
|
/**
|
|
@@ -833,6 +889,7 @@ export class Registry {
|
|
|
833
889
|
if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
|
|
834
890
|
this.utterances.delete(inboxID)
|
|
835
891
|
this.emit({ type: "heard", sessionID, inboxID })
|
|
892
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
836
893
|
}
|
|
837
894
|
|
|
838
895
|
/** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
|
|
@@ -864,13 +921,16 @@ export class Registry {
|
|
|
864
921
|
const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
|
|
865
922
|
this.rootSpeech.delete(key)
|
|
866
923
|
for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
|
|
924
|
+
this.tapSafely((tap, tenant) => tap.reply(tenant, sessionID, messageID, ordinal, text), sessionID)
|
|
867
925
|
// Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
|
|
868
926
|
if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
869
927
|
}
|
|
870
928
|
|
|
871
929
|
private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
|
|
872
930
|
this.heardFrom(state.sessionID, text)
|
|
873
|
-
|
|
931
|
+
const seq = state.seq++
|
|
932
|
+
this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq, text })
|
|
933
|
+
this.tapSafely((tap, tenant) => tap.sentence(tenant, state.sessionID, messageID, ordinal, seq, text), state.sessionID)
|
|
874
934
|
}
|
|
875
935
|
|
|
876
936
|
/** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
|
|
@@ -1174,6 +1234,15 @@ export class Registry {
|
|
|
1174
1234
|
}
|
|
1175
1235
|
|
|
1176
1236
|
/** Something in the session's tree is working or waiting for the user (typed input steers then). */
|
|
1237
|
+
/**
|
|
1238
|
+
* Whether a process reports a staged revert for the session (feature "revert"): anything admitted into it would
|
|
1239
|
+
* commit the revert (OpenCode's next prompt does), so hub deliveries and act prompts wait or are refused.
|
|
1240
|
+
*/
|
|
1241
|
+
revertStaged(sessionID: string): boolean {
|
|
1242
|
+
for (const conn of this.all()) if (this.reports(conn, sessionID)?.revert) return true
|
|
1243
|
+
return false
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1177
1246
|
running(sessionID: string): boolean {
|
|
1178
1247
|
const state = this.browserFields(sessionID).state
|
|
1179
1248
|
return state === "working" || state === "blocked"
|
|
@@ -1296,6 +1365,7 @@ export class Registry {
|
|
|
1296
1365
|
updated: info.updated,
|
|
1297
1366
|
...(terminal ? { open: true, terminal } : {}),
|
|
1298
1367
|
...(info.revert ? { revert: info.revert } : {}),
|
|
1368
|
+
...(info.revertCommitted ? { revertCommitted: info.revertCommitted } : {}),
|
|
1299
1369
|
...(info.agent !== undefined ? { agent: info.agent } : {}),
|
|
1300
1370
|
...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
|
|
1301
1371
|
...fields,
|
|
@@ -1745,10 +1815,12 @@ export class Registry {
|
|
|
1745
1815
|
recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
|
|
1746
1816
|
const origin = this.permissions.get(requestID)
|
|
1747
1817
|
if (!origin) return false
|
|
1748
|
-
|
|
1818
|
+
// (R-03) Without recommends it only links the Decision: no why either.
|
|
1819
|
+
const recommends = recommendation.recommends === "approve"
|
|
1820
|
+
const why = !recommends || recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
|
|
1749
1821
|
origin.recommendation = {
|
|
1750
1822
|
by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
|
|
1751
|
-
recommends: "approve",
|
|
1823
|
+
...(recommends ? { recommends: "approve" as const } : {}),
|
|
1752
1824
|
...(why ? { why } : {}),
|
|
1753
1825
|
...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
|
|
1754
1826
|
}
|
|
@@ -1788,6 +1860,19 @@ export class Registry {
|
|
|
1788
1860
|
}
|
|
1789
1861
|
}
|
|
1790
1862
|
|
|
1863
|
+
/**
|
|
1864
|
+
* The user dismissed the session's last run failure: its status board stops showing it (consoles and the Concierge
|
|
1865
|
+
* see the state leave "error") until it fails again. True when there was one to clear.
|
|
1866
|
+
*/
|
|
1867
|
+
dismissError(sessionID: string): boolean {
|
|
1868
|
+
const rootID = this.rootOf(sessionID)
|
|
1869
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
1870
|
+
if (rootID === undefined || !board?.observe({ type: "session.error.dismissed", data: { sessionID } })) return false
|
|
1871
|
+
this.changed(this.sessionTenant(rootID))
|
|
1872
|
+
this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
|
|
1873
|
+
return true
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1791
1876
|
/** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
|
|
1792
1877
|
cancelTurns(sessionID: string): void {
|
|
1793
1878
|
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Committed revert windows (feature "revert", docs/session-view.md → Fork and revert). A committed revert removed a
|
|
2
|
+
// session's history from the reverted message on; hub message rows (agent messages the session sent or got) from
|
|
3
|
+
// the reverted message's time until the revert was staged belonged to that history (later ones were held for after
|
|
4
|
+
// it, decision 3q94ez94). The spoke reports the latest window (SessionInfo.revertCommitted); the hub keeps every
|
|
5
|
+
// window per (user, session) in the StateStore, so GET /api/sessions/:id/messages leaves those rows out for good
|
|
6
|
+
// (also after the spoke restarts or another process reports the session).
|
|
7
|
+
import type { RevertCommitted } from "../protocol"
|
|
8
|
+
import type { StateStore } from "./backend/types"
|
|
9
|
+
|
|
10
|
+
const COLLECTION = "revertWindows"
|
|
11
|
+
/** Windows kept per session (the oldest go first). */
|
|
12
|
+
export const REVERT_WINDOWS_MAX = 50
|
|
13
|
+
|
|
14
|
+
export interface RevertWindow {
|
|
15
|
+
readonly from: number
|
|
16
|
+
readonly until: number
|
|
17
|
+
/** Commit time (identifies the commit). */
|
|
18
|
+
readonly at: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Stored {
|
|
22
|
+
readonly windows: readonly RevertWindow[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether a row at `at` falls into one of the windows. */
|
|
26
|
+
export function inRevertWindow(at: unknown, windows: readonly RevertWindow[]): boolean {
|
|
27
|
+
return typeof at === "number" && windows.some((w) => at >= w.from && at < w.until)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RevertWindows {
|
|
31
|
+
/** Windows known stored (by user, session and the whole window): the frames that repeat them read nothing. */
|
|
32
|
+
private readonly stored = new Set<string>()
|
|
33
|
+
|
|
34
|
+
constructor(private readonly state: StateStore) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A reported window (each sessions frame repeats it): kept unless known stored. Only a stored (or already present)
|
|
38
|
+
* window is remembered, so one reported first without its start (nothing to store), or whose write failed, is taken
|
|
39
|
+
* again from the next frame. Errors are the caller's.
|
|
40
|
+
*/
|
|
41
|
+
async observe(userID: string, sessionID: string, committed: RevertCommitted): Promise<void> {
|
|
42
|
+
if (committed.from === undefined || committed.until <= committed.from) return
|
|
43
|
+
const key = JSON.stringify([userID, sessionID, committed.from, committed.until, committed.at])
|
|
44
|
+
if (this.stored.has(key)) return
|
|
45
|
+
if (await this.record(userID, sessionID, committed)) this.stored.add(key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(userID: string, sessionID: string): Promise<readonly RevertWindow[]> {
|
|
49
|
+
return (await this.state.get<Stored>(userID, COLLECTION, sessionID))?.value.windows ?? []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keeps a reported window (one without a start removes no rows: skipped). True when it is stored now (new, or
|
|
54
|
+
* already there); false when skipped or the write lost a race (version check).
|
|
55
|
+
*/
|
|
56
|
+
async record(userID: string, sessionID: string, committed: RevertCommitted): Promise<boolean> {
|
|
57
|
+
if (committed.from === undefined || committed.until <= committed.from) return false
|
|
58
|
+
const window: RevertWindow = { from: committed.from, until: committed.until, at: committed.at }
|
|
59
|
+
const current = await this.state.get<Stored>(userID, COLLECTION, sessionID)
|
|
60
|
+
const windows = current?.value.windows ?? []
|
|
61
|
+
if (windows.some((w) => w.at === window.at && w.from === window.from && w.until === window.until)) return true
|
|
62
|
+
const next = [...windows, window].slice(-REVERT_WINDOWS_MAX)
|
|
63
|
+
const result = await this.state.put<Stored>(userID, COLLECTION, sessionID, { windows: next }, current ? { ifVersion: current.version } : {})
|
|
64
|
+
return result.ok
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/central/spoke-ws.ts
CHANGED
|
@@ -61,9 +61,12 @@ export interface Notifier {
|
|
|
61
61
|
|
|
62
62
|
/** The spoke answered with an error, or disconnected before answering. */
|
|
63
63
|
export class SpokeError extends Error {
|
|
64
|
-
|
|
64
|
+
/** The refusal's protocol code (Res.code, e.g. "busy", "not_found"), when the spoke sent one. */
|
|
65
|
+
readonly code?: string
|
|
66
|
+
constructor(message: string, code?: string) {
|
|
65
67
|
super(message)
|
|
66
68
|
this.name = "SpokeError"
|
|
69
|
+
if (code !== undefined) this.code = code
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -518,6 +521,8 @@ export class SpokeHub {
|
|
|
518
521
|
const owner = this.options.registry.sessionTenant(sessionID)
|
|
519
522
|
if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
|
|
520
523
|
}
|
|
524
|
+
// A dismissed error: the session's status board is this pod's (its connection is here), not the calling pod's.
|
|
525
|
+
if (call.kind === "spoke" && call.req.op === "dismissError") this.options.registry.dismissError(call.req.sessionID)
|
|
521
526
|
return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
|
|
522
527
|
}
|
|
523
528
|
|
|
@@ -669,7 +674,7 @@ export class SpokeHub {
|
|
|
669
674
|
}
|
|
670
675
|
|
|
671
676
|
/** Resolves or rejects the pending request a `res` frame answers. */
|
|
672
|
-
private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
|
|
677
|
+
private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string; readonly code?: string }): void {
|
|
673
678
|
const pending = this.pending.get(message.id)
|
|
674
679
|
if (!pending || pending.connID !== connID) return
|
|
675
680
|
this.pending.delete(message.id)
|
|
@@ -683,7 +688,7 @@ export class SpokeHub {
|
|
|
683
688
|
return pending.resolve(message.result)
|
|
684
689
|
}
|
|
685
690
|
if (message.ok) pending.resolve(message.result)
|
|
686
|
-
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed
|
|
691
|
+
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`, message.code))
|
|
687
692
|
}
|
|
688
693
|
|
|
689
694
|
private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
|
|
@@ -547,7 +547,7 @@ export class TeamStaffingService {
|
|
|
547
547
|
/** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
|
|
548
548
|
private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
|
|
549
549
|
if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
|
|
550
|
-
if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send
|
|
550
|
+
if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send idempotencyKey (the Idempotency-Key header) instead" })
|
|
551
551
|
const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
|
|
552
552
|
if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
|
|
553
553
|
if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
|
|
@@ -567,7 +567,7 @@ export class TeamStaffingService {
|
|
|
567
567
|
...(agentProfile !== undefined ? { agentProfile } : {}),
|
|
568
568
|
labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
|
|
569
569
|
}
|
|
570
|
-
validateLaunchSpec(spec)
|
|
570
|
+
validateLaunchSpec(spec, { staffing: true })
|
|
571
571
|
return spec
|
|
572
572
|
}
|
|
573
573
|
|
package/src/index.ts
CHANGED
|
@@ -27,9 +27,10 @@ import path from "node:path"
|
|
|
27
27
|
import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
|
|
28
28
|
import { PromptReferences } from "./spoke/prompt-refs"
|
|
29
29
|
import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
|
|
30
|
-
import { replyPermission, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
|
|
30
|
+
import { listedNames, namesReader, replyPermission, serverNames, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
|
|
31
31
|
import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
|
|
32
32
|
import { joinProcess, type Membership } from "./spoke/singleton"
|
|
33
|
+
import { applySessionContext, sortTools } from "./spoke/session-context"
|
|
33
34
|
import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
|
|
34
35
|
|
|
35
36
|
/** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
|
|
@@ -171,15 +172,24 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
|
171
172
|
function permissionLocation(ctx: Context): PermissionLocation {
|
|
172
173
|
const permission = ctx.permission as any
|
|
173
174
|
const vcs = (ctx as any).vcs
|
|
175
|
+
const skill = (ctx as any).skill
|
|
176
|
+
const command = (ctx as any).command
|
|
174
177
|
return {
|
|
175
178
|
directory: ctx.location.directory,
|
|
176
179
|
get: (input) => permission.get(input),
|
|
177
180
|
reply: (input) => permission.reply(input),
|
|
178
|
-
// OpenCode's plugin vcs
|
|
181
|
+
// OpenCode's plugin vcs, skill and command lists answer for this instance's location only (whatever location is passed).
|
|
179
182
|
...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
|
|
183
|
+
...(skill?.list && command?.list ? { names: () => localNamesOf(skill, command, ctx.location.directory) } : {}),
|
|
180
184
|
}
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
/** A plugin instance's own location's skill and command names (its lists answer only for that location). */
|
|
188
|
+
async function localNamesOf(skill: any, command: any, directory: string) {
|
|
189
|
+
const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
|
|
190
|
+
return { skills: listedNames(skills), commands: listedNames(commands) }
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
/** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
|
|
184
194
|
function vcsOf(vcs: any, directory: string) {
|
|
185
195
|
return {
|
|
@@ -206,7 +216,10 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
206
216
|
const skill = (ctx as any).skill
|
|
207
217
|
const command = (ctx as any).command
|
|
208
218
|
const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
|
|
209
|
-
const
|
|
219
|
+
const namesRead = namesReader(locations, async () => {
|
|
220
|
+
const client = canList(kind) ? await ownClient(kind).catch(() => undefined) : undefined
|
|
221
|
+
return client?.skill && client?.command ? serverNames(client) : undefined
|
|
222
|
+
})
|
|
210
223
|
return {
|
|
211
224
|
directory: ctx.location.directory,
|
|
212
225
|
opencodeVersion: ctx.app?.version ?? "unknown",
|
|
@@ -253,14 +266,9 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
253
266
|
unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
|
|
254
267
|
...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
|
|
255
268
|
// Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
|
|
260
|
-
return { skills: names(skills), commands: names(commands) }
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
: {}),
|
|
269
|
+
// Read in the session's own directory (src/spoke/locations.ts); unknown (undefined): the profile's skills and
|
|
270
|
+
// commands are not offered, never checked against another folder's names.
|
|
271
|
+
...((skill?.list && command?.list) || canList(kind) ? { localNames: (directory: string) => namesRead(directory) } : {}),
|
|
264
272
|
profileTool: typeof (ctx as any).tool?.transform === "function",
|
|
265
273
|
// A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
|
|
266
274
|
...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
|
|
@@ -364,12 +372,16 @@ export default definePlugin({
|
|
|
364
372
|
// A hub profile: after OpenCode's own parts (local instructions win), for this session only.
|
|
365
373
|
for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
|
|
366
374
|
if (event.tools && !spoke?.profiles.toolVisible(event.sessionID)) delete event.tools[PROFILE_TOOL]
|
|
367
|
-
// (CON-04) The hub's session context: its parts after the profile's, its
|
|
368
|
-
|
|
375
|
+
// (CON-04) The hub's session context: its stable parts after the profile's, its board after the conversation
|
|
376
|
+
// (V-01: the prompt cache survives a board change), its denied tools removed, the kept tools in name order.
|
|
377
|
+
if (spoke) applySessionContext(event as any, spoke.contexts.systemParts(event.sessionID), spoke.contexts.tailParts(event.sessionID))
|
|
369
378
|
if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
|
|
370
379
|
// The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
|
|
371
380
|
// the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
|
|
372
|
-
if (event.tools && event.agent === CONCIERGE_AGENT)
|
|
381
|
+
if (event.tools && event.agent === CONCIERGE_AGENT) {
|
|
382
|
+
for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
|
|
383
|
+
sortTools(event.tools as Record<string, unknown>)
|
|
384
|
+
}
|
|
373
385
|
}),
|
|
374
386
|
),
|
|
375
387
|
// The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
|