@huynle/hub 0.6.1 → 0.7.1
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 +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The voice agent's task board: its background workers (child sessions) with their state, last activity and
|
|
2
|
+
// how long, built from the OpenCode events this process sees. The context hook adds the rendered board to every
|
|
3
|
+
// voice turn, so the agent can answer "what's running?" or "how's the deploy going?" without a tool call, and
|
|
4
|
+
// the stop tool (hub_task_stop) checks with it that a worker belongs to the session asking. Pure: time is
|
|
5
|
+
// passed in.
|
|
6
|
+
import { summarizeToolInput } from "../protocol"
|
|
7
|
+
|
|
8
|
+
/** Tasks shown on the board, and the longest line. */
|
|
9
|
+
export const TASK_BOARD_MAX = 8
|
|
10
|
+
export const TASK_LINE_MAX = 200
|
|
11
|
+
/** Tasks remembered in this process (finished ones are forgotten first). */
|
|
12
|
+
const TASK_LIMIT = 300
|
|
13
|
+
const TITLE_MAX = 60
|
|
14
|
+
const TEXT_MAX = 160
|
|
15
|
+
/** Tool call IDs remembered per task to name their activity. */
|
|
16
|
+
const TOOL_NAMES_MAX = 20
|
|
17
|
+
|
|
18
|
+
export type TaskState = "working" | "blocked" | "done" | "failed" | "stopped"
|
|
19
|
+
|
|
20
|
+
export interface Task {
|
|
21
|
+
readonly id: string
|
|
22
|
+
readonly parentID: string
|
|
23
|
+
readonly title: string
|
|
24
|
+
readonly state: TaskState
|
|
25
|
+
/** ms epoch the current run started. */
|
|
26
|
+
readonly started: number
|
|
27
|
+
/** ms epoch the run ended (done, failed, stopped). */
|
|
28
|
+
readonly ended?: number
|
|
29
|
+
/** Since when it waits for the user (blocked). */
|
|
30
|
+
readonly blockedAt?: number
|
|
31
|
+
/** The latest tool it ran ("shell · bun test"). */
|
|
32
|
+
readonly activity?: string
|
|
33
|
+
/** Its latest assistant text (the result once done). */
|
|
34
|
+
readonly result?: string
|
|
35
|
+
readonly error?: string
|
|
36
|
+
/** Why it is blocked ("needs permission: …", "has a question: …"). */
|
|
37
|
+
readonly blocker?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Entry {
|
|
41
|
+
id: string
|
|
42
|
+
parentID: string
|
|
43
|
+
title: string
|
|
44
|
+
running: boolean
|
|
45
|
+
outcome?: "done" | "failed" | "stopped"
|
|
46
|
+
started: number
|
|
47
|
+
ended?: number
|
|
48
|
+
activity?: string
|
|
49
|
+
result?: string
|
|
50
|
+
error?: string
|
|
51
|
+
/** Pending permission / form ID → its description, in order. */
|
|
52
|
+
blockers: Map<string, { text: string; at: number }>
|
|
53
|
+
tools: Map<string, string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
|
|
57
|
+
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
|
|
58
|
+
const oneLine = (text: string, max: number) => {
|
|
59
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
60
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const LIVE = (e: Entry) => e.running || e.blockers.size > 0
|
|
64
|
+
|
|
65
|
+
export class TaskBoard {
|
|
66
|
+
private readonly entries = new Map<string, Entry>()
|
|
67
|
+
private readonly limit: number
|
|
68
|
+
|
|
69
|
+
constructor({ limit = TASK_LIMIT }: { limit?: number } = {}) {
|
|
70
|
+
this.limit = limit
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
observe(event: { type: string; data?: unknown }, now: number): void {
|
|
74
|
+
const data = record(event.data)
|
|
75
|
+
const sessionID = str(data.sessionID) ?? str(record(data.form).sessionID)
|
|
76
|
+
if (!sessionID) return
|
|
77
|
+
if (event.type === "session.created") {
|
|
78
|
+
const parentID = str(data.parentID)
|
|
79
|
+
if (!parentID) return
|
|
80
|
+
this.entries.delete(sessionID)
|
|
81
|
+
this.entries.set(sessionID, {
|
|
82
|
+
id: sessionID,
|
|
83
|
+
parentID,
|
|
84
|
+
title: oneLine(str(data.title) ?? str(data.agent) ?? "worker", TITLE_MAX),
|
|
85
|
+
running: false,
|
|
86
|
+
started: now,
|
|
87
|
+
blockers: new Map(),
|
|
88
|
+
tools: new Map(),
|
|
89
|
+
})
|
|
90
|
+
this.prune()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const entry = this.entries.get(sessionID)
|
|
94
|
+
if (!entry) return
|
|
95
|
+
switch (event.type) {
|
|
96
|
+
case "session.renamed":
|
|
97
|
+
if (str(data.title)) entry.title = oneLine(data.title, TITLE_MAX)
|
|
98
|
+
return
|
|
99
|
+
case "session.deleted":
|
|
100
|
+
this.entries.delete(sessionID)
|
|
101
|
+
return
|
|
102
|
+
case "session.execution.started":
|
|
103
|
+
if (!entry.running) entry.started = now
|
|
104
|
+
entry.running = true
|
|
105
|
+
entry.outcome = entry.ended = entry.error = undefined
|
|
106
|
+
return
|
|
107
|
+
case "session.execution.succeeded":
|
|
108
|
+
case "session.execution.failed":
|
|
109
|
+
case "session.execution.interrupted":
|
|
110
|
+
entry.running = false
|
|
111
|
+
entry.ended = now
|
|
112
|
+
entry.blockers.clear()
|
|
113
|
+
entry.outcome = event.type.endsWith("succeeded") ? "done" : event.type.endsWith("failed") ? "failed" : "stopped"
|
|
114
|
+
if (entry.outcome === "failed") entry.error = oneLine(str(record(data.error).message) ?? "failed", TEXT_MAX)
|
|
115
|
+
return
|
|
116
|
+
case "session.tool.input.started": {
|
|
117
|
+
const id = str(data.id)
|
|
118
|
+
const name = str(data.name)
|
|
119
|
+
if (!id || !name) return
|
|
120
|
+
entry.tools.set(id, name)
|
|
121
|
+
if (entry.tools.size > TOOL_NAMES_MAX) entry.tools.delete(entry.tools.keys().next().value!)
|
|
122
|
+
entry.activity = name
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
case "session.tool.called": {
|
|
126
|
+
const name = entry.tools.get(str(data.id) ?? "") ?? str(data.name) ?? "tool"
|
|
127
|
+
const summary = str(data.summary) ?? summarizeToolInput(data.input)
|
|
128
|
+
entry.activity = oneLine(summary ? `${name} · ${summary}` : name, TEXT_MAX)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
case "session.text.ended":
|
|
132
|
+
if (str(data.text)?.trim()) entry.result = oneLine(data.text, TEXT_MAX)
|
|
133
|
+
return
|
|
134
|
+
case "permission.asked": {
|
|
135
|
+
const id = str(data.id)
|
|
136
|
+
if (!id) return
|
|
137
|
+
const what = [str(data.action), Array.isArray(data.resources) ? data.resources.filter((r: unknown) => typeof r === "string").join(", ") : ""].filter(Boolean).join(": ")
|
|
138
|
+
entry.blockers.set(`per ${id}`, { text: oneLine(what ? `needs permission: ${what}` : "needs permission", TEXT_MAX), at: now })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
case "permission.replied":
|
|
142
|
+
entry.blockers.delete(`per ${str(data.requestID)}`)
|
|
143
|
+
return
|
|
144
|
+
case "form.created": {
|
|
145
|
+
const form = record(data.form)
|
|
146
|
+
const id = str(form.id)
|
|
147
|
+
if (!id) return
|
|
148
|
+
entry.blockers.set(`frm ${id}`, { text: oneLine(str(form.title) ? `has a question: ${form.title}` : "has a question", TEXT_MAX), at: now })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
case "form.replied":
|
|
152
|
+
case "form.cancelled":
|
|
153
|
+
entry.blockers.delete(`frm ${str(data.id) ?? str(record(data.form).id)}`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The workers a session started: live ones first (newest first), then finished ones (latest first). */
|
|
159
|
+
children(parentID: string): Task[] {
|
|
160
|
+
const mine = [...this.entries.values()].filter((e) => e.parentID === parentID)
|
|
161
|
+
const live = mine.filter(LIVE).sort((a, b) => b.started - a.started)
|
|
162
|
+
const finished = mine.filter((e) => !LIVE(e)).sort((a, b) => (b.ended ?? b.started) - (a.ended ?? a.started))
|
|
163
|
+
return [...live, ...finished].map(view)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get(id: string): Task | undefined {
|
|
167
|
+
const entry = this.entries.get(id)
|
|
168
|
+
return entry && view(entry)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The session that started a known worker. */
|
|
172
|
+
parentOf(id: string): string | undefined {
|
|
173
|
+
return this.entries.get(id)?.parentID
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private prune(): void {
|
|
177
|
+
while (this.entries.size > this.limit) {
|
|
178
|
+
const finished = [...this.entries.values()].filter((e) => !LIVE(e)).sort((a, b) => (a.ended ?? a.started) - (b.ended ?? b.started))[0]
|
|
179
|
+
this.entries.delete(finished?.id ?? this.entries.keys().next().value!)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function view(entry: Entry): Task {
|
|
185
|
+
const blocker = [...entry.blockers.values()][0]
|
|
186
|
+
const state: TaskState = blocker ? "blocked" : entry.running ? "working" : (entry.outcome ?? "working")
|
|
187
|
+
return {
|
|
188
|
+
id: entry.id,
|
|
189
|
+
parentID: entry.parentID,
|
|
190
|
+
title: entry.title,
|
|
191
|
+
state,
|
|
192
|
+
started: entry.started,
|
|
193
|
+
...(entry.ended !== undefined && !entry.running ? { ended: entry.ended } : {}),
|
|
194
|
+
...(blocker ? { blocker: blocker.text, blockedAt: blocker.at } : {}),
|
|
195
|
+
...(entry.activity ? { activity: entry.activity } : {}),
|
|
196
|
+
...(entry.result ? { result: entry.result } : {}),
|
|
197
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** "45s", "2m 10s", "1h 5m". */
|
|
202
|
+
export function duration(ms: number): string {
|
|
203
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
204
|
+
if (s < 60) return `${s}s`
|
|
205
|
+
const m = Math.floor(s / 60)
|
|
206
|
+
if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`
|
|
207
|
+
return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.floor(m / 60)}h`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function line(task: Task, now: number): string {
|
|
211
|
+
const head = `- "${task.title}" (sessionID ${task.id}): `
|
|
212
|
+
let rest: string
|
|
213
|
+
switch (task.state) {
|
|
214
|
+
case "blocked":
|
|
215
|
+
rest = `blocked ${duration(now - (task.blockedAt ?? task.started))}, ${task.blocker}`
|
|
216
|
+
break
|
|
217
|
+
case "working":
|
|
218
|
+
rest = `working ${duration(now - task.started)}${task.activity ? `, last: ${task.activity}` : ""}`
|
|
219
|
+
break
|
|
220
|
+
case "done":
|
|
221
|
+
rest = `done ${duration(now - (task.ended ?? now))} ago (took ${duration((task.ended ?? now) - task.started)})${task.result ? `: ${task.result}` : ""}`
|
|
222
|
+
break
|
|
223
|
+
case "failed":
|
|
224
|
+
rest = `failed ${duration(now - (task.ended ?? now))} ago${task.error ? `: ${task.error}` : ""}`
|
|
225
|
+
break
|
|
226
|
+
case "stopped":
|
|
227
|
+
rest = `stopped ${duration(now - (task.ended ?? now))} ago`
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
const text = head + rest
|
|
231
|
+
return text.length > TASK_LINE_MAX ? `${text.slice(0, TASK_LINE_MAX - 1)}…` : text
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The board for the voice agent's system prompt; undefined when it started no workers. */
|
|
235
|
+
export function renderTaskBoard(tasks: readonly Task[], now: number, { max = TASK_BOARD_MAX }: { max?: number } = {}): string | undefined {
|
|
236
|
+
if (!tasks.length) return undefined
|
|
237
|
+
const shown = tasks.slice(0, max)
|
|
238
|
+
return [
|
|
239
|
+
"Task board: your background workers right now (live; answer questions about them from this, no tool call needed; use a sessionID to continue or stop that worker):",
|
|
240
|
+
...shown.map((task) => line(task, now)),
|
|
241
|
+
...(tasks.length > shown.length ? [`(${tasks.length - shown.length} older tasks not shown)`] : []),
|
|
242
|
+
].join("\n")
|
|
243
|
+
}
|
package/src/spoke/timeline.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { PROMPT_IMAGE_MIMES, type PromptImage } from "../protocol"
|
|
1
|
+
import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
|
|
2
|
+
import { PROMPT_SOURCE } from "./conversation"
|
|
2
3
|
|
|
3
4
|
/** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
|
|
4
5
|
export interface TimelineFile {
|
|
@@ -12,46 +13,223 @@ export interface TimelineItem {
|
|
|
12
13
|
readonly messageID: string
|
|
13
14
|
readonly partID?: string
|
|
14
15
|
readonly sessionID: string
|
|
15
|
-
|
|
16
|
+
/** "message": a hub message delivered to the session (TimelineMessageItem). */
|
|
17
|
+
readonly kind: "user" | "assistant" | "reasoning" | "tool" | "message"
|
|
16
18
|
readonly text?: string
|
|
17
19
|
readonly name?: string
|
|
18
20
|
readonly state?: "running" | "done" | "failed"
|
|
19
21
|
readonly at?: number
|
|
20
22
|
readonly completed?: number
|
|
21
23
|
readonly files?: readonly TimelineFile[]
|
|
24
|
+
/**
|
|
25
|
+
* Ordering position: rows sort by it (never by `at`, whose clocks differ between parts). It follows the order
|
|
26
|
+
* of the session's messages and of the parts within a message, and depends only on the message's own creation
|
|
27
|
+
* time and its predecessors' (so it stays the same while history pages or the context window move).
|
|
28
|
+
*/
|
|
29
|
+
readonly pos?: number
|
|
30
|
+
/** Tool rows: the tool call ID (live activity items use it as their ID). */
|
|
31
|
+
readonly callID?: string
|
|
32
|
+
/** Subagent (subagent/task) tool rows: the child session it runs in. */
|
|
33
|
+
readonly childSessionID?: string
|
|
34
|
+
/** Subagent tool rows: the task's short description. */
|
|
35
|
+
readonly title?: string
|
|
36
|
+
/** Assistant text rows: which text part of the message this is (0, 1, …), as in live text deltas. */
|
|
37
|
+
readonly ordinal?: number
|
|
38
|
+
/** Message rows: always "in" here (the hub adds the session's sent messages). */
|
|
39
|
+
readonly direction?: TimelineMessageItem["direction"]
|
|
40
|
+
/** Message rows: who sent it, how it was addressed, what it answers. */
|
|
41
|
+
readonly message?: TimelineMessageItem["message"]
|
|
22
42
|
}
|
|
23
43
|
|
|
44
|
+
/** Parts of one message a position leaves room for (later parts share the last slot). */
|
|
45
|
+
const POS_PARTS = 1000
|
|
46
|
+
const SUBAGENT_TOOLS = new Set(["subagent", "task"])
|
|
47
|
+
const TITLE_MAX = 160
|
|
48
|
+
|
|
24
49
|
const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
|
|
25
50
|
|
|
26
51
|
/** Projects OpenCode messages into stable, ordered, payload-light phone timeline rows. */
|
|
27
52
|
export function timelineItems(sessionID: string, messages: readonly unknown[]): TimelineItem[] {
|
|
28
53
|
const out: TimelineItem[] = []
|
|
54
|
+
let previous = -Infinity
|
|
29
55
|
for (const raw of messages) {
|
|
30
56
|
const message = record(raw)
|
|
31
57
|
if (typeof message.id !== "string") continue
|
|
32
58
|
const at = typeof message.time?.created === "number" ? message.time.created : undefined
|
|
59
|
+
// Messages keep the session's order even when one was created earlier than the one before it (a queued prompt).
|
|
60
|
+
const start = Math.max(at === undefined ? -Infinity : at * POS_PARTS, previous + POS_PARTS)
|
|
61
|
+
const first = (previous = Number.isFinite(start) ? start : 0)
|
|
62
|
+
const pos = (index: number) => first + Math.min(index, POS_PARTS - 1)
|
|
33
63
|
if (message.type === "user") {
|
|
34
64
|
const text = typeof message.text === "string" ? message.text : Array.isArray(message.content) ? message.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("") : ""
|
|
35
65
|
const files = userFiles(message.files)
|
|
36
|
-
if (text || files.length) out.push({ key: `${message.id}:text`, messageID: message.id, sessionID, kind: "user", text, ...(at === undefined ? {} : { at }), ...(files.length ? { files } : {}) })
|
|
66
|
+
if (text || files.length) out.push({ key: `${message.id}:text`, messageID: message.id, sessionID, kind: "user", text, ...(at === undefined ? {} : { at }), ...(files.length ? { files } : {}), pos: pos(0) })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (message.type === "synthetic") {
|
|
70
|
+
const row = hubMessageRow(sessionID, message, at, pos(0))
|
|
71
|
+
if (row) out.push(row)
|
|
37
72
|
continue
|
|
38
73
|
}
|
|
39
74
|
if (message.type !== "assistant" || !Array.isArray(message.content)) continue
|
|
75
|
+
let texts = 0
|
|
40
76
|
message.content.forEach((part: any, index: number) => {
|
|
41
77
|
if (!part || typeof part !== "object") return
|
|
42
78
|
const partID = String(index)
|
|
43
|
-
const base = { key: `${message.id}:${partID}`, messageID: message.id, partID, sessionID, ...(at === undefined ? {} : { at }) }
|
|
44
|
-
if (part.type === "text" && typeof part.text === "string"
|
|
79
|
+
const base = { key: `${message.id}:${partID}`, messageID: message.id, partID, sessionID, ...(at === undefined ? {} : { at }), pos: pos(index) }
|
|
80
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
81
|
+
const ordinal = texts++
|
|
82
|
+
if (part.text) out.push({ ...base, kind: "assistant", text: part.text, ordinal })
|
|
83
|
+
}
|
|
45
84
|
if (part.type === "reasoning") out.push({ ...base, kind: "reasoning", text: String(part.text ?? ""), state: part.time?.completed === undefined ? "running" : "done", ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}), ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}) })
|
|
46
85
|
if (part.type === "tool") {
|
|
47
86
|
const status = part.state?.status
|
|
48
|
-
|
|
87
|
+
const name = String(part.name ?? "tool")
|
|
88
|
+
const spawn = SUBAGENT_TOOLS.has(name) ? subagentOf(part.state) : {}
|
|
89
|
+
out.push({
|
|
90
|
+
...base,
|
|
91
|
+
kind: "tool",
|
|
92
|
+
name,
|
|
93
|
+
state: status === "error" ? "failed" : status === "completed" ? "done" : "running",
|
|
94
|
+
...(typeof part.id === "string" && part.id ? { callID: part.id } : {}),
|
|
95
|
+
...spawn,
|
|
96
|
+
...(typeof part.time?.created === "number" ? { at: part.time.created } : {}),
|
|
97
|
+
...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}),
|
|
98
|
+
})
|
|
49
99
|
}
|
|
50
100
|
})
|
|
51
101
|
}
|
|
52
102
|
return out
|
|
53
103
|
}
|
|
54
104
|
|
|
105
|
+
/** A message the hub delivered (synthetic, with deliverMetadata): a "message" row; other synthetic messages: none. */
|
|
106
|
+
function hubMessageRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineMessageItem | undefined {
|
|
107
|
+
const meta = record(message.metadata)
|
|
108
|
+
const id = meta.messageID
|
|
109
|
+
if (meta.source !== PROMPT_SOURCE || !isID(id) || !MESSAGE_KINDS.includes(meta.kind) || !isID(meta.from) || !isID(meta.to)) return undefined
|
|
110
|
+
const text = typeof message.text === "string" ? message.text : ""
|
|
111
|
+
return {
|
|
112
|
+
key: `${message.id}:message`,
|
|
113
|
+
messageID: id,
|
|
114
|
+
sessionID,
|
|
115
|
+
kind: "message",
|
|
116
|
+
text: meta.kind === "primer" ? text : messageBody(text, id),
|
|
117
|
+
...(at === undefined ? {} : { at }),
|
|
118
|
+
pos,
|
|
119
|
+
direction: "in",
|
|
120
|
+
message: { kind: meta.kind as MessageKind, from: meta.from, to: meta.to, ...(isID(meta.replyTo) ? { replyTo: meta.replyTo } : {}) },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isID = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= DELIVER_ID_MAX
|
|
125
|
+
|
|
126
|
+
/** A line of the envelope body escaped by renderEnvelope (a leading backslash before a header-like line). */
|
|
127
|
+
const ESCAPED = /^(\s*)\\(\\*(?:\[hub\]|-{3,}|↩))/i
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The body of a hub envelope (packages/sdk/src/envelope.ts renderEnvelope): the lines between its boundaries,
|
|
131
|
+
* unescaped. Text without boundaries (a reminder) shows its first line without the "[hub]" tag.
|
|
132
|
+
*/
|
|
133
|
+
function messageBody(text: string, id: string): string {
|
|
134
|
+
const lines = text.split("\n")
|
|
135
|
+
const boundary = `----- ${id} -----`
|
|
136
|
+
const start = lines.indexOf(boundary)
|
|
137
|
+
const end = lines.lastIndexOf(boundary)
|
|
138
|
+
if (start < 0 || end <= start) return lines[0]!.replace(/^\[hub\]\s*/i, "")
|
|
139
|
+
return lines
|
|
140
|
+
.slice(start + 1, end)
|
|
141
|
+
.map((line) => line.replace(ESCAPED, "$1$2"))
|
|
142
|
+
.join("\n")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const CHILD_SESSION = /sessionID:?\s*"?(ses_[A-Za-z0-9]+)/
|
|
146
|
+
|
|
147
|
+
/** The child session (from the tool's metadata, else its output) and description of a subagent tool call. */
|
|
148
|
+
function subagentOf(rawState: unknown): { childSessionID?: string; title?: string } {
|
|
149
|
+
const state = record(rawState)
|
|
150
|
+
const description = typeof state.input?.description === "string" ? state.input.description.trim().split("\n")[0]!.slice(0, TITLE_MAX) : ""
|
|
151
|
+
let child = typeof state.metadata?.sessionID === "string" && state.metadata.sessionID.startsWith("ses_") ? (state.metadata.sessionID as string) : undefined
|
|
152
|
+
if (!child) {
|
|
153
|
+
const outputs = [state.output, state.error, ...(Array.isArray(state.content) ? state.content.map((c: any) => c?.text) : [])]
|
|
154
|
+
for (const text of outputs) {
|
|
155
|
+
const match = typeof text === "string" ? CHILD_SESSION.exec(text) : null
|
|
156
|
+
if (match) {
|
|
157
|
+
child = match[1]
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ...(child ? { childSessionID: child } : {}), ...(description ? { title: description } : {}) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A newest-first timeline page: rows in chronological order, and the cursor of the rows before them. */
|
|
166
|
+
export interface NewestPage {
|
|
167
|
+
readonly items: TimelineItem[]
|
|
168
|
+
/** Pass as `before` for the next older page; absent at the beginning of the conversation. */
|
|
169
|
+
readonly older?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `limit` rows just before the row keyed `before` (the newest rows without it). The cursor is the key of
|
|
174
|
+
* the oldest row returned, so it stays valid while new messages arrive (an offset would shift). A page starts
|
|
175
|
+
* at a message boundary when the message fits in twice the limit; larger messages are split.
|
|
176
|
+
*/
|
|
177
|
+
export function newestPage(items: readonly TimelineItem[], limit: number, before?: string): NewestPage {
|
|
178
|
+
const end = before === undefined ? items.length : items.findIndex((item) => item.key === before)
|
|
179
|
+
if (end < 0) throw new Error("Invalid timeline cursor")
|
|
180
|
+
let start = Math.max(0, end - limit)
|
|
181
|
+
const cap = Math.max(0, end - limit * 2)
|
|
182
|
+
while (start > cap && items[start - 1]!.messageID === items[start]!.messageID) start--
|
|
183
|
+
const page = items.slice(start, end)
|
|
184
|
+
return { items: page, ...(start > 0 && page.length ? { older: page[0]!.key } : {}) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Short-lived per-session timeline rows, so paging through a history reads OpenCode's whole history once rather
|
|
189
|
+
* than once per page. Entries expire after `ttlMs` and are dropped when the session changes (invalidate).
|
|
190
|
+
*/
|
|
191
|
+
export class TimelineCache {
|
|
192
|
+
private readonly entries = new Map<string, { items: TimelineItem[]; at: number }>()
|
|
193
|
+
/** Bumped on invalidation: a load started before it is not stored. */
|
|
194
|
+
private readonly generations = new Map<string, number>()
|
|
195
|
+
private readonly ttlMs: number
|
|
196
|
+
private readonly max: number
|
|
197
|
+
private readonly now: () => number
|
|
198
|
+
|
|
199
|
+
constructor(options: { ttlMs?: number; max?: number; now?: () => number } = {}) {
|
|
200
|
+
this.ttlMs = options.ttlMs ?? 30_000
|
|
201
|
+
this.max = options.max ?? 20
|
|
202
|
+
this.now = options.now ?? Date.now
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get size(): number {
|
|
206
|
+
return this.entries.size
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The cached rows of `sessionID`, else (or with `fresh`) loaded and cached. */
|
|
210
|
+
async get(sessionID: string, load: () => Promise<TimelineItem[]>, fresh = false): Promise<TimelineItem[]> {
|
|
211
|
+
const cached = this.entries.get(sessionID)
|
|
212
|
+
if (!fresh && cached && this.now() - cached.at < this.ttlMs) {
|
|
213
|
+
this.entries.delete(sessionID)
|
|
214
|
+
this.entries.set(sessionID, cached)
|
|
215
|
+
return cached.items
|
|
216
|
+
}
|
|
217
|
+
const generation = this.generations.get(sessionID) ?? 0
|
|
218
|
+
const items = await load()
|
|
219
|
+
if ((this.generations.get(sessionID) ?? 0) !== generation) return items
|
|
220
|
+
this.entries.delete(sessionID)
|
|
221
|
+
this.entries.set(sessionID, { items, at: this.now() })
|
|
222
|
+
while (this.entries.size > this.max) this.entries.delete(this.entries.keys().next().value!)
|
|
223
|
+
return items
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
invalidate(sessionID: string): void {
|
|
227
|
+
this.entries.delete(sessionID)
|
|
228
|
+
if (this.generations.size > 1000) this.generations.clear()
|
|
229
|
+
this.generations.set(sessionID, (this.generations.get(sessionID) ?? 0) + 1)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
55
233
|
/** Attachment metadata (never the bytes) of a user message's files. */
|
|
56
234
|
function userFiles(raw: unknown): TimelineFile[] {
|
|
57
235
|
if (!Array.isArray(raw)) return []
|
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 { FORWARDED_EVENTS, type SessionInfo, type TerminalInfo } from "../protocol"
|
|
4
|
+
import { compactActivity, FORWARDED_EVENTS, type JobInfo, type SessionInfo, 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
|
|
@@ -58,6 +58,9 @@ export function eventSessionID(event: EventLike): string | undefined {
|
|
|
58
58
|
return undefined
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Shells and terminals remembered (for the session of their exit). */
|
|
62
|
+
const JOBS_KNOWN = 1000
|
|
63
|
+
|
|
61
64
|
const RUN_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
|
|
62
65
|
|
|
63
66
|
export class SessionTracker {
|
|
@@ -66,6 +69,10 @@ export class SessionTracker {
|
|
|
66
69
|
private readonly running = new Set<string>()
|
|
67
70
|
/** Pending permission or form ID → its session. */
|
|
68
71
|
private readonly waiting = new Map<string, string>()
|
|
72
|
+
/** Sessions OpenCode reports busy or retrying (session.status). */
|
|
73
|
+
private readonly busy = new Set<string>()
|
|
74
|
+
/** Shell or terminal ID → its session, while known (running or exited, until deleted). */
|
|
75
|
+
private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
|
|
69
76
|
|
|
70
77
|
/** Applies an event seen in this process. Returns true when the snapshot may have changed. */
|
|
71
78
|
observe(event: EventLike, now: number): boolean {
|
|
@@ -94,6 +101,8 @@ export class SessionTracker {
|
|
|
94
101
|
}
|
|
95
102
|
case "session.deleted":
|
|
96
103
|
this.running.delete(id)
|
|
104
|
+
this.busy.delete(id)
|
|
105
|
+
for (const [jobID, job] of this.jobs) if (job.sessionID === id) this.jobs.delete(jobID)
|
|
97
106
|
for (const [key, sessionID] of this.waiting) if (sessionID === id) this.waiting.delete(key)
|
|
98
107
|
return this.entries.delete(id)
|
|
99
108
|
default: {
|
|
@@ -178,18 +187,73 @@ export class SessionTracker {
|
|
|
178
187
|
return true
|
|
179
188
|
}
|
|
180
189
|
|
|
181
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Root sessions with a run in progress, a busy or retrying status, a running shell or terminal, or a pending
|
|
192
|
+
* permission or form somewhere in their tree.
|
|
193
|
+
*/
|
|
182
194
|
active(): string[] {
|
|
183
195
|
const roots = new Set<string>()
|
|
184
|
-
|
|
196
|
+
const jobs = [...this.jobs.values()].filter((j) => j.running).map((j) => j.sessionID)
|
|
197
|
+
for (const id of [...this.running, ...this.busy, ...jobs, ...this.waiting.values()]) roots.add(this.rootOf(id))
|
|
185
198
|
return [...roots]
|
|
186
199
|
}
|
|
187
200
|
|
|
201
|
+
/** Sessions OpenCode reported busy or retrying, or with a running shell or terminal (not their roots). */
|
|
202
|
+
working(): string[] {
|
|
203
|
+
const jobs = [...this.jobs.values()].filter((j) => j.running).map((j) => j.sessionID)
|
|
204
|
+
return [...new Set([...this.busy, ...jobs])]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Directories of known sessions (where to list shells), most recently updated first. */
|
|
208
|
+
directories(max: number): string[] {
|
|
209
|
+
const sorted = [...this.entries.values()].filter((e) => e.directory.startsWith("/")).sort((a, b) => b.updated - a.updated)
|
|
210
|
+
return [...new Set(sorted.map((e) => e.directory))].slice(0, max)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The session a shell or terminal belongs to, while known. */
|
|
214
|
+
jobSession(jobID: string): string | undefined {
|
|
215
|
+
return this.jobs.get(jobID)?.sessionID
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Replaces a session's status and running jobs with a snapshot (fetched at startup or after a reconnect). */
|
|
219
|
+
seed(sessionID: string, snapshot: { readonly status: "idle" | "busy" | "retry"; readonly jobs: readonly JobInfo[] }): void {
|
|
220
|
+
if (snapshot.status === "idle") this.busy.delete(sessionID)
|
|
221
|
+
else this.busy.add(sessionID)
|
|
222
|
+
for (const [jobID, job] of this.jobs) if (job.sessionID === sessionID && job.running) this.jobs.delete(jobID)
|
|
223
|
+
for (const job of snapshot.jobs) this.setJob(job.id, sessionID, job.status === "running")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Session status and shells/terminals (compacted so the session of an exit is resolved). */
|
|
227
|
+
private trackJobs(event: EventLike): void {
|
|
228
|
+
if (event.type !== "session.status" && !event.type.startsWith("shell.") && !event.type.startsWith("pty.") && !event.type.startsWith("session.shell.")) return
|
|
229
|
+
const compact = compactActivity({ type: event.type, data: record(event.data) }, (id) => this.jobSession(id))
|
|
230
|
+
if (!compact) return
|
|
231
|
+
const data = compact.data as Record<string, any>
|
|
232
|
+
const sessionID = data.sessionID as string
|
|
233
|
+
if (compact.type === "session.status") {
|
|
234
|
+
if (data.status.type === "idle") this.busy.delete(sessionID)
|
|
235
|
+
else this.busy.add(sessionID)
|
|
236
|
+
} else if (compact.type.endsWith(".deleted")) this.jobs.delete(data.id)
|
|
237
|
+
else if (typeof data.id === "string" && typeof data.status === "string") this.setJob(data.id, sessionID, data.status === "running")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Remembers a job, keeping at most JOBS_KNOWN (oldest dropped). */
|
|
241
|
+
private setJob(id: string, sessionID: string, running: boolean): void {
|
|
242
|
+
this.jobs.delete(id)
|
|
243
|
+
this.jobs.set(id, { sessionID, running })
|
|
244
|
+
if (this.jobs.size > JOBS_KNOWN) this.jobs.delete(this.jobs.keys().next().value!)
|
|
245
|
+
}
|
|
246
|
+
|
|
188
247
|
private trackActive(event: EventLike): void {
|
|
248
|
+
this.trackJobs(event)
|
|
189
249
|
const data = record(event.data)
|
|
190
250
|
const sessionID = eventSessionID(event)
|
|
191
251
|
if (event.type === "session.execution.started" && sessionID) this.running.add(sessionID)
|
|
192
|
-
else if (RUN_END.has(event.type) && sessionID)
|
|
252
|
+
else if (RUN_END.has(event.type) && sessionID) {
|
|
253
|
+
this.running.delete(sessionID)
|
|
254
|
+
// OpenCode v2 sends no idle status: a run's end ends a busy status (seeded or reported) too.
|
|
255
|
+
this.busy.delete(sessionID)
|
|
256
|
+
}
|
|
193
257
|
else if (event.type === "permission.asked" && sessionID && typeof data.id === "string") this.waiting.set(`per ${data.id}`, sessionID)
|
|
194
258
|
else if (event.type === "permission.replied" && typeof data.requestID === "string") this.waiting.delete(`per ${data.requestID}`)
|
|
195
259
|
else if (event.type === "form.created" && sessionID && typeof record(data.form).id === "string") this.waiting.set(`frm ${record(data.form).id}`, sessionID)
|
|
@@ -198,6 +262,17 @@ export class SessionTracker {
|
|
|
198
262
|
}
|
|
199
263
|
}
|
|
200
264
|
|
|
265
|
+
/** The session itself has a run in progress or OpenCode reports it busy or retrying. */
|
|
266
|
+
isBusy(sessionID: string): boolean {
|
|
267
|
+
return this.running.has(sessionID) || this.busy.has(sessionID)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A form (question) is open in the session itself. */
|
|
271
|
+
formOpenIn(sessionID: string): boolean {
|
|
272
|
+
for (const [key, id] of this.waiting) if (id === sessionID && key.startsWith("frm ")) return true
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
|
|
201
276
|
/** A form (question) this process saw asked and not yet replied or cancelled. */
|
|
202
277
|
formPending(formID: string): boolean {
|
|
203
278
|
return this.waiting.has(`frm ${formID}`)
|