dsh-live-trace 0.1.0
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/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- package/src/cli/working/state.js +528 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is the orca doing?
|
|
3
|
+
*
|
|
4
|
+
* The animation answers to the session's real activity, so this module turns
|
|
5
|
+
* the same record stream the trace dashboard consumes into one of a handful of
|
|
6
|
+
* work states. It is deliberately pure: give it records and a clock, read back
|
|
7
|
+
* a state.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-live-working/state
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every state the scene can draw.
|
|
14
|
+
*
|
|
15
|
+
* `thinking`, `typing` and `writing` are the subdivisions of "the model is
|
|
16
|
+
* working": reasoning, prose/code streaming, and a file being written. They
|
|
17
|
+
* look slightly different because they are different kinds of work.
|
|
18
|
+
*/
|
|
19
|
+
export const WORK_STATES = [
|
|
20
|
+
'sleep',
|
|
21
|
+
'thinking',
|
|
22
|
+
'typing',
|
|
23
|
+
'writing',
|
|
24
|
+
'waiting',
|
|
25
|
+
'reading',
|
|
26
|
+
'searching',
|
|
27
|
+
'calling',
|
|
28
|
+
// A subagent has finished and is calling back: the telephone rings and the
|
|
29
|
+
// orca answers it with the subagent's own reply.
|
|
30
|
+
'ringing'
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long a callback keeps ringing.
|
|
35
|
+
*
|
|
36
|
+
* Long enough to read the reply off the bubble, short enough that it settles
|
|
37
|
+
* back to what the session is actually doing.
|
|
38
|
+
*/
|
|
39
|
+
export const CALL_MS = 2500
|
|
40
|
+
/** How long a callback rings before it settles. */
|
|
41
|
+
export const RING_MS = 14_000
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How long a running shell command may stay silent before the orca dozes off.
|
|
45
|
+
* A build that prints nothing for this long looks exactly like no work at all.
|
|
46
|
+
*/
|
|
47
|
+
export const SILENT_MS = 8000
|
|
48
|
+
|
|
49
|
+
/** Tool names grouped by what they make the orca do. */
|
|
50
|
+
export const TOOL_KINDS = {
|
|
51
|
+
shell: ['bash', 'shell', 'sh', 'exec', 'run', 'terminal', 'command'],
|
|
52
|
+
read: ['read', 'read_file', 'readfile', 'view', 'open', 'cat', 'list', 'ls', 'glob', 'grep', 'search_files'],
|
|
53
|
+
search: ['web_search', 'websearch', 'web_fetch', 'webfetch', 'fetch', 'browse', 'search_web'],
|
|
54
|
+
subagent: ['task', 'agent', 'subagent', 'delegate', 'spawn_agent', 'task_tool'],
|
|
55
|
+
write: ['write', 'write_file', 'writefile', 'edit', 'edit_file', 'apply_patch', 'patch', 'multiedit', 'notebook_edit']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What kind of work a tool name implies.
|
|
60
|
+
*
|
|
61
|
+
* The lookup is loose on purpose: tool names arrive in several styles
|
|
62
|
+
* (`read_file`, `ReadFile`, `read-file`) and a miss should degrade to "the
|
|
63
|
+
* model is working", never to a crash.
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} name
|
|
66
|
+
* @returns {'shell' | 'read' | 'search' | 'subagent' | 'write' | null}
|
|
67
|
+
*/
|
|
68
|
+
export function classifyTool(name) {
|
|
69
|
+
if (typeof name !== 'string' || name.length === 0) return null
|
|
70
|
+
const needle = name.toLowerCase().replace(/[^a-z]/g, '')
|
|
71
|
+
for (const [kind, names] of Object.entries(TOOL_KINDS)) {
|
|
72
|
+
for (const candidate of names) {
|
|
73
|
+
const target = candidate.replace(/[^a-z]/g, '')
|
|
74
|
+
if (needle === target || needle.endsWith(target) || needle.startsWith(target)) return kind
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/* ------------------------------------------------------------------ *
|
|
82
|
+
* Subagents: the telephone queue
|
|
83
|
+
* ------------------------------------------------------------------ */
|
|
84
|
+
|
|
85
|
+
/** One argument value as it appears in a call, clipped to something readable. */
|
|
86
|
+
function quoteValue(value, limit = 44) {
|
|
87
|
+
if (value === null) return 'null'
|
|
88
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
89
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
|
90
|
+
if (typeof text !== 'string') return '…'
|
|
91
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
92
|
+
return `"${flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat}"`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The call as it was written, not just the instruction inside it.
|
|
97
|
+
*
|
|
98
|
+
* A subagent invocation is more than its prompt: the bubble shows the actual
|
|
99
|
+
* call — `task(description="…", prompt="…")` — so it is clear what was asked
|
|
100
|
+
* and how, and how many arguments came with it.
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} tool
|
|
103
|
+
* @param {unknown} rawArgs the plugin's `arguments`, usually a JSON string
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function callFormat(tool, rawArgs) {
|
|
107
|
+
const name = typeof tool === 'string' && tool.trim().length > 0 ? tool.trim() : 'subagent'
|
|
108
|
+
if (rawArgs === undefined || rawArgs === null) return `${name}()`
|
|
109
|
+
let args = rawArgs
|
|
110
|
+
if (typeof args === 'string') {
|
|
111
|
+
const text = args.trim()
|
|
112
|
+
if (text.length === 0) return `${name}()`
|
|
113
|
+
try {
|
|
114
|
+
args = JSON.parse(text)
|
|
115
|
+
} catch {
|
|
116
|
+
return `${name}(${text.replace(/\s+/g, ' ')})`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return `${name}(${quoteValue(args)})`
|
|
120
|
+
const keys = Object.keys(args)
|
|
121
|
+
if (keys.length === 0) return `${name}()`
|
|
122
|
+
return `${name}(${keys.map((key) => `${key}=${quoteValue(args[key])}`).join(', ')})`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* When a record actually happened.
|
|
127
|
+
*
|
|
128
|
+
* The plugin timestamps every entry, and a viewer replaying the backlog gets
|
|
129
|
+
* hours of history at once. Using the wall clock instead made every replayed
|
|
130
|
+
* event look like it had just happened — so starting the command rang the
|
|
131
|
+
* telephone for a subagent that finished five minutes ago.
|
|
132
|
+
*
|
|
133
|
+
* @param {any} record
|
|
134
|
+
* @param {number} fallback
|
|
135
|
+
* @returns {number}
|
|
136
|
+
*/
|
|
137
|
+
export function eventTime(record, fallback) {
|
|
138
|
+
const raw = record?.entry?.time ?? record?.time ?? record?.at
|
|
139
|
+
return Number.isFinite(raw) ? raw : fallback
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The text a finished subagent came back with. */
|
|
143
|
+
export function replyTextOf(entry) {
|
|
144
|
+
const candidates = [entry?.output?.text, entry?.result, entry?.rawText, entry?.text]
|
|
145
|
+
for (const candidate of candidates) {
|
|
146
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
|
147
|
+
}
|
|
148
|
+
return ''
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A subagent as the scene needs it. */
|
|
152
|
+
function subagentView(state, record) {
|
|
153
|
+
if (record === null || record === undefined) return null
|
|
154
|
+
const pending = state.subagentOrder
|
|
155
|
+
.map((callId) => state.subagents.get(callId))
|
|
156
|
+
.filter((item) => item !== undefined && item.status === 'pending')
|
|
157
|
+
return {
|
|
158
|
+
index: record.index,
|
|
159
|
+
total: state.subagentCount,
|
|
160
|
+
// Everything dispatched before this one that has not finished yet.
|
|
161
|
+
queued: pending.filter((item) => item.callId !== record.callId).length,
|
|
162
|
+
tool: record.tool,
|
|
163
|
+
format: record.format,
|
|
164
|
+
input: record.input,
|
|
165
|
+
reply: record.status === 'done' ? record.result : null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The number of subagents dispatched but not yet finished. */
|
|
170
|
+
function pendingSubagents(state) {
|
|
171
|
+
let count = 0
|
|
172
|
+
for (const record of state.subagents.values()) if (record.status === 'pending') count += 1
|
|
173
|
+
return count
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A background subagent replies in two steps, and only the second one is an
|
|
179
|
+
* answer.
|
|
180
|
+
*
|
|
181
|
+
* Dispatching one returns immediately with `started subagent <id>` — an
|
|
182
|
+
* acknowledgement that the work was handed out, not the work. The subagent's
|
|
183
|
+
* actual answer arrives later, as a message from the agent. Treating the
|
|
184
|
+
* acknowledgement as the reply made every background subagent look finished the
|
|
185
|
+
* instant it started, which emptied the queue and rang the telephone
|
|
186
|
+
* immediately.
|
|
187
|
+
*/
|
|
188
|
+
const DISPATCH_ACK = /^started\s+subagent\s+(\S+)/i
|
|
189
|
+
const AGENT_MESSAGE = /^Agent\s+(\S+)\s+sent a message:\s*([\s\S]*)$/i
|
|
190
|
+
const AGENT_FINISHED = /^Background\s+subagent\s+(\S+)\s+finished/i
|
|
191
|
+
const CLOSING_MESSAGE = /Its closing message:\s*([\s\S]*)$/i
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Read a subagent notice.
|
|
195
|
+
*
|
|
196
|
+
* @param {unknown} text
|
|
197
|
+
* @returns {{ handle: string, reply: string | null } | null}
|
|
198
|
+
*/
|
|
199
|
+
export function parseSubagentNotice(text) {
|
|
200
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
201
|
+
const trimmed = text.trim()
|
|
202
|
+
const sent = AGENT_MESSAGE.exec(trimmed)
|
|
203
|
+
if (sent !== null) return { handle: sent[1], reply: sent[2].trim() }
|
|
204
|
+
const closing = CLOSING_MESSAGE.exec(trimmed)
|
|
205
|
+
if (closing !== null && AGENT_FINISHED.test(trimmed)) {
|
|
206
|
+
return { handle: AGENT_FINISHED.exec(trimmed)[1], reply: closing[1].trim() }
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Is this result just the acknowledgement that a subagent was dispatched? */
|
|
212
|
+
export function isDispatchAck(text) {
|
|
213
|
+
return typeof text === 'string' && DISPATCH_ACK.test(text.trim())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A fresh activity model. */
|
|
217
|
+
export function createWorkState() {
|
|
218
|
+
return {
|
|
219
|
+
status: 'idle',
|
|
220
|
+
streamActive: false,
|
|
221
|
+
streamKind: null,
|
|
222
|
+
activeTool: null,
|
|
223
|
+
lastOutputAt: 0,
|
|
224
|
+
lastEventAt: 0,
|
|
225
|
+
subagentCount: 0,
|
|
226
|
+
subagent: null,
|
|
227
|
+
/** Every subagent call in this session, keyed by call id. */
|
|
228
|
+
subagents: new Map(),
|
|
229
|
+
/** Call ids in the order they were dispatched: the queue. */
|
|
230
|
+
subagentOrder: [],
|
|
231
|
+
/** The subagent whose call is currently in flight. */
|
|
232
|
+
activeSubagent: null,
|
|
233
|
+
/** The most recent callback, which is what the telephone is ringing about. */
|
|
234
|
+
lastReply: null,
|
|
235
|
+
// The tail of whatever the model last put on the wire: what the little
|
|
236
|
+
// window on the desk shows.
|
|
237
|
+
preview: ''
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The text a tool call was given, as one short line, for the bubble. */
|
|
242
|
+
function summarise(input) {
|
|
243
|
+
if (typeof input !== 'string' || input.trim().length === 0) return null
|
|
244
|
+
return input.replace(/\s+/g, ' ').trim()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Arguments that best describe what a call is for, most specific first. */
|
|
248
|
+
const ARGUMENT_KEYS = ['description', 'prompt', 'task', 'instructions', 'query', 'command', 'path', 'file_path', 'pattern']
|
|
249
|
+
|
|
250
|
+
/** Pull a human-meaningful argument out of a tool call's arguments. */
|
|
251
|
+
function describeCall(entry) {
|
|
252
|
+
let args = entry.arguments ?? entry.args ?? entry.input
|
|
253
|
+
// The plugin publishes the call arguments as the raw JSON string it received.
|
|
254
|
+
if (typeof args === 'string') {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(args)
|
|
257
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed
|
|
258
|
+
} catch {
|
|
259
|
+
return summarise(args)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// With no arguments field at all, the entry itself may carry them (older
|
|
263
|
+
// plugin versions, or a hand-written record).
|
|
264
|
+
if (args === null || args === undefined) args = entry
|
|
265
|
+
if (args !== null && typeof args === 'object' && args !== entry) {
|
|
266
|
+
for (const key of ARGUMENT_KEYS) {
|
|
267
|
+
const value = args[key]
|
|
268
|
+
if (typeof value === 'string' && value.trim().length > 0) return summarise(value)
|
|
269
|
+
}
|
|
270
|
+
const first = Object.values(args).find((value) => typeof value === 'string' && value.trim().length > 0)
|
|
271
|
+
if (typeof first === 'string') return summarise(first)
|
|
272
|
+
}
|
|
273
|
+
if (args === entry) {
|
|
274
|
+
for (const key of ARGUMENT_KEYS) {
|
|
275
|
+
const value = entry[key]
|
|
276
|
+
if (typeof value === 'string' && value.trim().length > 0) return summarise(value)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Fall back to the entry's own one-line summary, which already contains the
|
|
280
|
+
// arguments but leads with the tool name.
|
|
281
|
+
const text = summarise(entry.text)
|
|
282
|
+
if (text === null) return null
|
|
283
|
+
const name = typeof entry.tool === 'string' ? entry.tool : null
|
|
284
|
+
if (name !== null && text.startsWith(name)) return summarise(text.slice(name.length)) ?? name
|
|
285
|
+
return text
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Fold one record into the activity model.
|
|
290
|
+
*
|
|
291
|
+
* @param {ReturnType<typeof createWorkState>} state
|
|
292
|
+
* @param {any} record
|
|
293
|
+
* @param {number} [now]
|
|
294
|
+
* @returns {{ changed: boolean, finishedTool: object | null }}
|
|
295
|
+
*/
|
|
296
|
+
export function reduceWork(state, record, now = Date.now()) {
|
|
297
|
+
if (record === null || typeof record !== 'object') return { changed: false, finishedTool: null }
|
|
298
|
+
|
|
299
|
+
if (record.kind === 'status') {
|
|
300
|
+
const status = record.status ?? 'idle'
|
|
301
|
+
const changed = state.status !== status || state.streamActive !== (record.streamActive === true)
|
|
302
|
+
state.status = status
|
|
303
|
+
state.streamActive = record.streamActive === true
|
|
304
|
+
state.lastEventAt = eventTime(record, now)
|
|
305
|
+
return { changed, finishedTool: null }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (record.kind === 'stream') {
|
|
309
|
+
const reasoning = typeof record.reasoning === 'string' ? record.reasoning : ''
|
|
310
|
+
const text = typeof record.text === 'string' ? record.text : ''
|
|
311
|
+
if (text.length > 0) state.preview = text
|
|
312
|
+
else if (reasoning.length > 0) state.preview = reasoning
|
|
313
|
+
const kind = reasoning.length > 0 ? 'thinking' : text.length > 0 ? 'typing' : state.streamKind
|
|
314
|
+
const changed = state.streamKind !== kind
|
|
315
|
+
state.streamKind = kind
|
|
316
|
+
state.streamActive = true
|
|
317
|
+
state.lastEventAt = eventTime(record, now)
|
|
318
|
+
return { changed, finishedTool: null }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (record.kind === 'stream-end') {
|
|
322
|
+
state.streamActive = false
|
|
323
|
+
state.streamKind = null
|
|
324
|
+
state.lastEventAt = eventTime(record, now)
|
|
325
|
+
return { changed: true, finishedTool: null }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (record.kind !== 'entry') return { changed: false, finishedTool: null }
|
|
329
|
+
|
|
330
|
+
const entry = record.entry
|
|
331
|
+
if (entry === null || entry === undefined) return { changed: false, finishedTool: null }
|
|
332
|
+
|
|
333
|
+
const at = eventTime(record, now)
|
|
334
|
+
const isTool = entry.tag === 'tool' || entry.tag === 'tool-call' || entry.tag === 'tool-result'
|
|
335
|
+
if (!isTool) {
|
|
336
|
+
// A subagent hanging up: its answer arrives as a message from the agent.
|
|
337
|
+
const notice = parseSubagentNotice(entry.text ?? entry.rawText)
|
|
338
|
+
if (notice !== null) {
|
|
339
|
+
const finished = [...state.subagents.values()].find(
|
|
340
|
+
(item) => item.handle === notice.handle && item.status === 'pending'
|
|
341
|
+
)
|
|
342
|
+
if (finished !== undefined) {
|
|
343
|
+
finished.status = 'done'
|
|
344
|
+
finished.result = notice.reply ?? ''
|
|
345
|
+
if (state.activeSubagent === finished.callId) state.activeSubagent = null
|
|
346
|
+
state.lastReply = { callId: finished.callId, index: finished.index, text: finished.result, at }
|
|
347
|
+
state.preview = finished.result
|
|
348
|
+
state.lastEventAt = at
|
|
349
|
+
return { changed: true, finishedTool: null }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (typeof entry.rawText === 'string' && entry.rawText.length > 0) state.preview = entry.rawText
|
|
353
|
+
else if (typeof entry.text === 'string' && entry.text.length > 0 && entry.tag === 'assistant') state.preview = entry.text
|
|
354
|
+
state.lastEventAt = at
|
|
355
|
+
return { changed: false, finishedTool: null }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const kind = classifyTool(entry.tool ?? entry.name)
|
|
359
|
+
const phase = entry.phase ?? (entry.tag === 'tool-result' ? 'result' : 'call')
|
|
360
|
+
|
|
361
|
+
if (phase === 'call') {
|
|
362
|
+
if (kind === 'subagent') {
|
|
363
|
+
const callId = entry.callId ?? entry.key ?? `subagent-${state.subagentCount + 1}`
|
|
364
|
+
state.subagentCount += 1
|
|
365
|
+
const name = entry.tool ?? entry.name ?? 'task'
|
|
366
|
+
const described = describeCall(entry)
|
|
367
|
+
const record = {
|
|
368
|
+
callId,
|
|
369
|
+
index: state.subagentCount,
|
|
370
|
+
tool: name,
|
|
371
|
+
// With the raw arguments (the current plugin) this is the call exactly
|
|
372
|
+
// as written. Without them it falls back to the one-line summary the
|
|
373
|
+
// entry already carries, so the bubble still shows a call rather than
|
|
374
|
+
// an empty `task()`.
|
|
375
|
+
format: entry.arguments === undefined
|
|
376
|
+
? (described === null ? `${name}()` : `${name}(${described})`)
|
|
377
|
+
: callFormat(name, entry.arguments),
|
|
378
|
+
input: described,
|
|
379
|
+
status: 'pending',
|
|
380
|
+
startedAt: at,
|
|
381
|
+
result: ''
|
|
382
|
+
}
|
|
383
|
+
state.subagents.set(callId, record)
|
|
384
|
+
state.subagentOrder.push(callId)
|
|
385
|
+
state.activeSubagent = callId
|
|
386
|
+
state.subagent = { index: record.index, input: describeCall(entry), name: record.tool }
|
|
387
|
+
state.preview = record.format
|
|
388
|
+
}
|
|
389
|
+
state.activeTool = {
|
|
390
|
+
callId: entry.callId ?? entry.key ?? null,
|
|
391
|
+
name: entry.tool ?? entry.name ?? 'tool',
|
|
392
|
+
kind,
|
|
393
|
+
at,
|
|
394
|
+
input: describeCall(entry)
|
|
395
|
+
}
|
|
396
|
+
state.preview = kind === 'subagent' ? undefined : (state.activeTool.input ?? state.preview)
|
|
397
|
+
// Shell output resets the silence clock so a chatty command never sleeps.
|
|
398
|
+
if (kind === 'shell') state.lastOutputAt = at
|
|
399
|
+
state.lastEventAt = now
|
|
400
|
+
return { changed: true, finishedTool: null }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A result settles the tool it belongs to; shell output also keeps the
|
|
404
|
+
// orca awake.
|
|
405
|
+
const settled = state.activeTool
|
|
406
|
+
const resultCallId = entry.callId ?? entry.key ?? null
|
|
407
|
+
if (settled !== null && (settled.callId === null || settled.callId === resultCallId)) {
|
|
408
|
+
state.activeTool = null
|
|
409
|
+
}
|
|
410
|
+
// A subagent hanging up: record its reply, which is what the telephone will
|
|
411
|
+
// be ringing about.
|
|
412
|
+
const callId = resultCallId ?? state.activeSubagent
|
|
413
|
+
const finished = callId === null ? undefined : state.subagents.get(callId)
|
|
414
|
+
if (finished !== undefined && finished.status === 'pending') {
|
|
415
|
+
const ack = replyTextOf(entry)
|
|
416
|
+
if (isDispatchAck(ack)) {
|
|
417
|
+
// Handed out, not finished: a background subagent keeps its place in the
|
|
418
|
+
// queue, and its answer arrives later as a message from the agent.
|
|
419
|
+
finished.background = true
|
|
420
|
+
finished.handle = DISPATCH_ACK.exec(ack.trim())[1]
|
|
421
|
+
// The acknowledgement comes back at once, so the tool call is over in
|
|
422
|
+
// milliseconds. Remember when the call was placed: the handset is held
|
|
423
|
+
// for a moment afterwards, or the whole gesture is never seen.
|
|
424
|
+
finished.dispatchedAt = at
|
|
425
|
+
state.lastReply = null
|
|
426
|
+
} else {
|
|
427
|
+
finished.status = 'done'
|
|
428
|
+
finished.result = ack
|
|
429
|
+
if (state.activeSubagent === callId) state.activeSubagent = null
|
|
430
|
+
state.lastReply = { callId, index: finished.index, text: finished.result, at }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (kind === 'shell' || settled?.kind === 'shell') {
|
|
434
|
+
state.lastOutputAt = at
|
|
435
|
+
if (settled?.kind === 'shell') state.activeTool = null
|
|
436
|
+
}
|
|
437
|
+
state.lastEventAt = at
|
|
438
|
+
return { changed: true, finishedTool: settled }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* What the little window on the desk should show.
|
|
443
|
+
*
|
|
444
|
+
* The tail of the model's output while it writes, otherwise the call it is
|
|
445
|
+
* running — the same text the telephone bubble uses.
|
|
446
|
+
*
|
|
447
|
+
* @param {ReturnType<typeof createWorkState>} state
|
|
448
|
+
* @returns {string}
|
|
449
|
+
*/
|
|
450
|
+
export function previewOf(state) {
|
|
451
|
+
const active = state.activeTool
|
|
452
|
+
if (active !== null && typeof active.input === 'string' && active.input.length > 0) return active.input
|
|
453
|
+
return typeof state.preview === 'string' ? state.preview : ''
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The state to draw right now.
|
|
458
|
+
*
|
|
459
|
+
* @param {ReturnType<typeof createWorkState>} state
|
|
460
|
+
* @param {number} [now]
|
|
461
|
+
* @returns {{ kind: string, subagent: { index: number, input: string | null } | null }}
|
|
462
|
+
*/
|
|
463
|
+
export function workStateOf(state, now = Date.now()) {
|
|
464
|
+
// How many subagents are still out. Every state carries it, so a caller can
|
|
465
|
+
// always ask the desk how deep the queue is.
|
|
466
|
+
const out = pendingSubagents(state)
|
|
467
|
+
const view = (kind, subagent, extra = {}) => ({ kind, subagent, waiting: false, pending: out, ...extra })
|
|
468
|
+
|
|
469
|
+
// A subagent calling back rings the telephone. Answering it is the most
|
|
470
|
+
// important thing on the desk, so it outranks everything else.
|
|
471
|
+
const reply = state.lastReply
|
|
472
|
+
if (reply !== null && typeof reply.at === 'number' && now - reply.at < RING_MS) {
|
|
473
|
+
const answered = state.subagents.get(reply.callId)
|
|
474
|
+
if (answered !== undefined) return view('ringing', subagentView(state, answered))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const tool = state.activeTool
|
|
478
|
+
if (tool !== null) {
|
|
479
|
+
if (tool.kind === 'subagent') {
|
|
480
|
+
const record = state.activeSubagent === null ? undefined : state.subagents.get(state.activeSubagent)
|
|
481
|
+
const held = record === undefined ? null : subagentView(state, record)
|
|
482
|
+
return view('calling', held ?? state.subagent)
|
|
483
|
+
}
|
|
484
|
+
if (tool.kind === 'search') return view('searching', null)
|
|
485
|
+
if (tool.kind === 'read') return view('reading', null)
|
|
486
|
+
if (tool.kind === 'write') return view('writing', null)
|
|
487
|
+
if (tool.kind === 'shell') {
|
|
488
|
+
// A command that has said nothing for a long time is indistinguishable
|
|
489
|
+
// from no work at all, so the orca falls asleep on it.
|
|
490
|
+
const silentFor = now - Math.max(state.lastOutputAt, tool.at)
|
|
491
|
+
return view(silentFor >= SILENT_MS ? 'sleep' : 'waiting', null)
|
|
492
|
+
}
|
|
493
|
+
return view('typing', null)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (state.streamKind === 'thinking') return view('thinking', null)
|
|
497
|
+
if (state.streamKind === 'typing') return view('typing', null)
|
|
498
|
+
|
|
499
|
+
// Just after handing work out, the handset is still in the orca's flipper.
|
|
500
|
+
// Without this the call is never seen: a background dispatch is acknowledged
|
|
501
|
+
// immediately, so the tool is gone before the handset has finished rising.
|
|
502
|
+
const calling = [...state.subagents.values()].filter(
|
|
503
|
+
(item) => item.status === 'pending' && Number.isFinite(item.dispatchedAt) && now - item.dispatchedAt < CALL_MS
|
|
504
|
+
)
|
|
505
|
+
if (calling.length > 0) {
|
|
506
|
+
const latest = calling.reduce((best, item) => (item.dispatchedAt > best.dispatchedAt ? item : best))
|
|
507
|
+
return view('calling', subagentView(state, latest))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Work handed out and nothing left to do: the orca dozes at the desk until
|
|
511
|
+
// one of the subagents rings back.
|
|
512
|
+
if (out > 0 && (state.status === 'idle' || state.status === 'error')) {
|
|
513
|
+
return view('sleep', null, { waiting: true })
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
switch (state.status) {
|
|
517
|
+
case 'running':
|
|
518
|
+
return view('typing', null)
|
|
519
|
+
case 'tool':
|
|
520
|
+
return view('waiting', null)
|
|
521
|
+
case 'waiting-approval':
|
|
522
|
+
return view('thinking', null)
|
|
523
|
+
case 'error':
|
|
524
|
+
return view('typing', null)
|
|
525
|
+
default:
|
|
526
|
+
return view('sleep', null)
|
|
527
|
+
}
|
|
528
|
+
}
|