pi-optchat 0.7.0 → 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/README.md +13 -6
- package/package.json +1 -1
- package/src/agent-view.ts +18 -5
- package/src/agents.ts +99 -14
- package/src/index.ts +7 -7
- package/src/inspector.ts +33 -15
- package/src/memory.ts +11 -0
- package/src/navigation.ts +9 -8
- package/src/runs.ts +8 -4
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ For headless use, pass `--optchat-profile work`.
|
|
|
48
48
|
| `/optchat agents` | Live agent tree and saved run history. |
|
|
49
49
|
| `/optchat agents model` | Subagent model and effort for this profile. |
|
|
50
50
|
| `/optchat usage` | Token usage and cost estimates. |
|
|
51
|
+
| `/optchat activity` | Memory gauge: view size, summaries catching up, running agents. |
|
|
51
52
|
| `/optchat instructions` | Edit this profile's `AGENTS.md`. |
|
|
52
53
|
| `/optchat browse` | Open a readable snapshot of memory: the shape of what the model sees, summaries you can open down to the original messages, and search that shows where each message is folded. Run again to refresh. |
|
|
53
54
|
| `/optchat import` | Import history, or resume/discard a paused import. |
|
|
@@ -103,9 +104,9 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
103
104
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
|
104
105
|
- Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
|
|
105
106
|
|
|
106
|
-
## Agents and
|
|
107
|
+
## Agents, usage and activity inspector
|
|
107
108
|
|
|
108
|
-
An **Agents | Usage** bar sits below the input.
|
|
109
|
+
An **Agents | Usage | Activity** bar sits below the input.
|
|
109
110
|
|
|
110
111
|
| Key | Action |
|
|
111
112
|
| --- | --- |
|
|
@@ -113,7 +114,7 @@ An **Agents | Usage** bar sits below the input.
|
|
|
113
114
|
| **Left/Right**, **Enter** | Pick and open a section |
|
|
114
115
|
| **Escape**, **Up**, or typing | Back to the editor |
|
|
115
116
|
| **F6** | Open Agents directly, keeping your draft |
|
|
116
|
-
| **Tab** |
|
|
117
|
+
| **Tab** | Cycle Agents, Usage and Activity |
|
|
117
118
|
|
|
118
119
|
Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
|
|
119
120
|
|
|
@@ -123,17 +124,23 @@ Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another
|
|
|
123
124
|
|
|
124
125
|
| Key | Action |
|
|
125
126
|
| --- | --- |
|
|
126
|
-
| **Escape**
|
|
127
|
-
| **Ctrl+
|
|
127
|
+
| **Escape** | Clear a draft, else back to the main chat |
|
|
128
|
+
| **Ctrl+C** | Clear a draft, else interrupt the agent's current step. Never ends the agent: queued messages go to it at once and it carries on; with none queued it waits for you (**interrupted · waiting for you**) until your next message, or a `tell` from the main agent, resumes it |
|
|
129
|
+
| **Up** (empty input) | Take your newest queued message back to edit; send it again, or clear it to drop it |
|
|
130
|
+
| **Ctrl+X** twice | Stop this agent and the agents it started (the only key that ends it) |
|
|
128
131
|
| **Page Up/Down**, mouse wheel | Scroll; back at the bottom it follows again. The wheel needs Pi's default fullscreen mode |
|
|
129
132
|
| **Ctrl+O** | Expand tool output (Pi's own toggle) |
|
|
130
133
|
|
|
131
|
-
Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
|
|
134
|
+
Guidance shows as queued until delivered, or undelivered if the child stops first. When you interrupt an agent with nothing queued, the agent that started it gets a one-line note instead of a report, so it isn't left waiting. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
|
|
132
135
|
|
|
133
136
|
**Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache. Costs are API prices, not your subscription bill.
|
|
134
137
|
|
|
135
138
|

|
|
136
139
|
|
|
140
|
+
**Activity** is a memory gauge: how many messages the profile holds and how much of the 128 KB view they fill, then either **Settled** or **Catching up · 12 of 40 summaries** with a progress bar counted from when the backlog last grew from empty. If summarizing keeps failing, the last error and the retry countdown show under it. It also counts running agents, and interrupted ones waiting for you; their list is on Agents. While summaries or agents are at work, the bar's Activity item gets a **●**.
|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
137
144
|
- Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
|
|
138
145
|
- Record counts are not request counts; retries and tool overhead can add records.
|
|
139
146
|
- Main-agent tracking starts with v0.3.0 (resumed sessions are backfilled). Older records without a parent session are left out of **This session**.
|
package/package.json
CHANGED
package/src/agent-view.ts
CHANGED
|
@@ -167,6 +167,7 @@ const statusOf = (run: RunInfo, children: Children) => {
|
|
|
167
167
|
if (!live) return run.state;
|
|
168
168
|
if (run.state === 'stopping') return 'stopping';
|
|
169
169
|
if (run.state === 'waiting') return 'waiting for its agents';
|
|
170
|
+
if (run.state === 'paused') return 'interrupted · waiting for you';
|
|
170
171
|
const tools = [...live.tools.values()].map(t => t.name);
|
|
171
172
|
return tools.length ? `running ${tools.join(', ')}` : live.streaming ? 'writing' : 'working';
|
|
172
173
|
};
|
|
@@ -207,7 +208,9 @@ export class AgentView implements Component, Focusable {
|
|
|
207
208
|
private readonly close = () => { if (!this.ended) { this.dispose(); this.options.done(); } };
|
|
208
209
|
dispose() { this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.close); }
|
|
209
210
|
invalidate() { this.editor.invalidate(); }
|
|
210
|
-
private get
|
|
211
|
+
private get state() { return this.options.children.live(this.options.id)?.info.state ?? ''; }
|
|
212
|
+
private get canMessage() { return ['running', 'waiting', 'paused'].includes(this.state); }
|
|
213
|
+
private get canInterrupt() { return ['running', 'waiting'].includes(this.state); }
|
|
211
214
|
private scroll(lines: number) { this.back = Math.max(0, Math.min(this.overflow, this.back + lines)); }
|
|
212
215
|
/** Wheel scrolling, in Pi's default fullscreen mode; in "regular" mode the terminal keeps the wheel. */
|
|
213
216
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
@@ -226,12 +229,19 @@ export class AgentView implements Component, Focusable {
|
|
|
226
229
|
if (this.ended) return;
|
|
227
230
|
const { children, id } = this.options;
|
|
228
231
|
const stopping = this.confirmStop; this.confirmStop = false; this.notice = '';
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
const escape = matchesKey(data, 'escape') && !this.editor.isShowingAutocomplete(), interrupt = matchesKey(data, 'ctrl+c');
|
|
233
|
+
if ((escape || interrupt) && this.editor.getText()) {
|
|
231
234
|
this.editor.setText(''); // First press clears a draft, as in the main editor.
|
|
232
235
|
return this.options.redraw();
|
|
233
236
|
}
|
|
234
|
-
if (
|
|
237
|
+
if (escape || interrupt && !this.canInterrupt) return this.close();
|
|
238
|
+
// Up on an empty input pulls the newest queued message back to edit: sending it queues the new text, clearing it drops it.
|
|
239
|
+
const pulled = matchesKey(data, 'up') && !this.editor.getText() && this.canMessage ? children.withdraw(id) : undefined;
|
|
240
|
+
if (pulled !== undefined) this.editor.setText(pulled);
|
|
241
|
+
else if (interrupt) {
|
|
242
|
+
children.interrupt(id).catch((error: unknown) => { this.notice = `Could not interrupt: ${error instanceof Error ? error.message : String(error)}`; })
|
|
243
|
+
.finally(() => { if (!this.ended) this.options.redraw(); });
|
|
244
|
+
} else if (matchesKey(data, 'ctrl+x')) {
|
|
235
245
|
if (!children.live(id)) this.notice = 'This agent has already finished.';
|
|
236
246
|
else if (stopping) children.stop(id).catch((error: unknown) => { this.notice = `Could not stop: ${String(error)}`; this.options.redraw(); });
|
|
237
247
|
else this.confirmStop = true;
|
|
@@ -287,9 +297,12 @@ export class AgentView implements Component, Focusable {
|
|
|
287
297
|
const end = lines.length - this.back;
|
|
288
298
|
const body = lines.slice(Math.max(0, end - this.bodyHeight), end);
|
|
289
299
|
while (body.length < this.bodyHeight) body.push('');
|
|
300
|
+
const editable = this.canMessage && run?.guidance.some(g => g.state === 'queued' && g.from === 'user');
|
|
301
|
+
const keys = [...this.editor.getText() ? ['Esc clear'] : ['Esc back to main', ...this.canInterrupt ? ['Ctrl+C interrupt'] : [], ...editable ? ['↑ edit queued'] : []],
|
|
302
|
+
...live ? ['Ctrl+X stop'] : [], ...this.overflow ? [this.back ? 'PgDn newer' : 'PgUp older'] : []];
|
|
290
303
|
const hint = this.confirmStop ? color('warning', ' Press Ctrl+X again to stop this agent and the agents it started')
|
|
291
304
|
: this.notice ? color('error', ` ${this.notice}`)
|
|
292
|
-
: color('dim', ` ${
|
|
305
|
+
: color('dim', ` ${keys.join(' · ')}`);
|
|
293
306
|
const scrolled = this.back ? color('warning', `↑ ${this.back} lines up `) : '';
|
|
294
307
|
const footer = [visibleWidth(hint) + visibleWidth(scrolled) < width ? `${hint}${' '.repeat(width - visibleWidth(hint) - visibleWidth(scrolled))}${scrolled}` : hint];
|
|
295
308
|
// On a terminal too short for everything, the header goes first; the input and footer stay.
|
package/src/agents.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface LiveRun {
|
|
|
23
23
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
24
24
|
tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
|
|
25
25
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
26
|
+
/** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
|
|
27
|
+
interruption?: RunInfo['guidance'];
|
|
28
|
+
/** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
|
|
29
|
+
requeueing?: Promise<void>;
|
|
26
30
|
/** Shared by the children of one spawn whose reports are delivered together, in spawn order. */
|
|
27
31
|
batch?: { ids: string[]; reports: Map<string, string> };
|
|
28
32
|
}
|
|
@@ -51,6 +55,8 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
51
55
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
52
56
|
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
53
57
|
const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
|
|
58
|
+
/** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
|
|
59
|
+
export const STEERABLE = 'Never block in a single command for more than about 60 seconds. To wait for something, poll in short separate tool calls (for example one `sleep 30` per call), so messages from your parent or the user can reach you between calls.';
|
|
54
60
|
|
|
55
61
|
// Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
|
|
56
62
|
// subagents must add them. Pi versions that do not export a factory simply do not get that extension.
|
|
@@ -211,7 +217,7 @@ export class Children {
|
|
|
211
217
|
const { id, directory, depth, parentId, connected } = o;
|
|
212
218
|
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
213
219
|
const delegation = delegates ? `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total.` : 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
214
|
-
const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
220
|
+
const instructions = [this.instructions(), delegation, STEERABLE, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
215
221
|
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
216
222
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
217
223
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
@@ -260,16 +266,21 @@ export class Children {
|
|
|
260
266
|
const message = args.message.trim();
|
|
261
267
|
if (!message) throw new Error('Message is empty.');
|
|
262
268
|
const text = `[${id}] ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
|
|
263
|
-
if (!
|
|
264
|
-
|
|
265
|
-
if (!parent || !['running', 'waiting'].includes(parent.info.state)) throw new Error('Your parent is no longer running.');
|
|
266
|
-
if (parent.info.state === 'waiting') { parent.pendingReports.push(text); parent.wake?.(); }
|
|
267
|
-
else await parent.session.steer(text);
|
|
268
|
-
this.changed();
|
|
269
|
-
return result(`Message sent to parent ${parentId}.`);
|
|
269
|
+
if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
|
|
270
|
+
return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
|
|
270
271
|
},
|
|
271
272
|
};
|
|
272
273
|
}
|
|
274
|
+
/** A mid-run message to a child's parent: false if that parent is no longer running. */
|
|
275
|
+
private async toParent(parentId: string | undefined, text: string) {
|
|
276
|
+
if (!parentId) { await this.report(text); return true; }
|
|
277
|
+
const parent = this.running.get(parentId);
|
|
278
|
+
if (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state)) return false;
|
|
279
|
+
if (parent.info.state === 'running') await parent.session.steer(text);
|
|
280
|
+
else { parent.pendingReports.push(text); parent.wake?.(); } // A paused parent keeps it until the user resumes it.
|
|
281
|
+
this.changed();
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
273
284
|
private async shutdown(session: AgentSession) {
|
|
274
285
|
try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
|
|
275
286
|
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
@@ -287,8 +298,36 @@ export class Children {
|
|
|
287
298
|
if (this.closing || info.handoff || state === 'stopping') throw new Error('Stopped before its first request.');
|
|
288
299
|
await session.prompt(prompt);
|
|
289
300
|
while (info.state !== 'stopping') {
|
|
301
|
+
if (live.interruption) {
|
|
302
|
+
const guidance = live.interruption.filter(g => g.state === 'queued');
|
|
303
|
+
// Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
|
|
304
|
+
// Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
|
|
305
|
+
await live.requeueing;
|
|
306
|
+
const texts = new Set(guidance.map(g => g.text));
|
|
307
|
+
await this.requeue(live, session.clearQueue().steering.filter(text => !texts.has(text)));
|
|
308
|
+
live.interruption = undefined;
|
|
309
|
+
if (!guidance.length) {
|
|
310
|
+
// Nothing to hand over: the run waits for its next message instead of ending, and whoever waits on its report hears why.
|
|
311
|
+
if (transition(info, 'paused')) {
|
|
312
|
+
this.save(info);
|
|
313
|
+
if (!info.connected) await this.toParent(info.parentId, `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`)
|
|
314
|
+
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
|
|
319
|
+
for (const g of guidance) g.state = 'delivered';
|
|
320
|
+
this.save(info);
|
|
321
|
+
await session.prompt(`Interrupted by the user:\n\n${guidance.map(g => g.text).join('\n\n')}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const paused = info.state === 'paused';
|
|
325
|
+
if (paused && !live.pendingGuidance.length) {
|
|
326
|
+
await new Promise<void>(resolve => { live.wake = resolve; }); live.wake = undefined;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
290
329
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
291
|
-
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
330
|
+
if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
292
331
|
if (live.pendingGuidance.length) {
|
|
293
332
|
transition(info, 'running'); this.save(info);
|
|
294
333
|
await session.prompt(live.pendingGuidance.shift()!);
|
|
@@ -357,20 +396,66 @@ export class Children {
|
|
|
357
396
|
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
358
397
|
const live = this.running.get(id);
|
|
359
398
|
if (!live && source === 'manager' && this.history.records.has(id)) return this.track(this.resume(id, message, caller));
|
|
360
|
-
if (live && !['running', 'waiting'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
399
|
+
if (live && !['running', 'waiting', 'paused'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
361
400
|
if (!live) throw new Error(`No running subagent ${id}.`);
|
|
362
401
|
const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
|
|
363
402
|
if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
|
|
364
403
|
const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
|
|
365
404
|
live.info.guidance.push(guidance); this.save(live.info);
|
|
366
405
|
try {
|
|
367
|
-
|
|
368
|
-
|
|
406
|
+
await live.requeueing; // Keeps the queue in the order messages were sent.
|
|
407
|
+
if (live.info.state === 'running' && !live.interruption) await live.session.steer(text);
|
|
408
|
+
else { live.pendingGuidance.push(text); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
|
|
369
409
|
}
|
|
370
410
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
371
411
|
if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
|
|
372
412
|
return 'Message queued for the next tool boundary.';
|
|
373
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The user's Ctrl+C, never destructive: aborts the current step. With guidance queued, the run continues at once with it;
|
|
416
|
+
* with none, it pauses until the next message (from the user or a tell). Only stop() ends a run.
|
|
417
|
+
*/
|
|
418
|
+
async interrupt(id: string): Promise<'continued' | 'paused'> {
|
|
419
|
+
const live = this.running.get(id);
|
|
420
|
+
if (live?.info.state === 'paused') return 'paused';
|
|
421
|
+
if (!live || !['running', 'waiting'].includes(live.info.state)) throw new Error(`No running subagent ${id}.`);
|
|
422
|
+
const guidance = live.info.guidance.filter(g => g.state === 'queued');
|
|
423
|
+
// Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
|
|
424
|
+
// They leave pendingGuidance here and Pi's steering queue in execute(), which then sends them in one prompt.
|
|
425
|
+
const texts = new Set(guidance.map(g => g.text));
|
|
426
|
+
live.pendingGuidance = live.pendingGuidance.filter(text => !texts.has(text));
|
|
427
|
+
live.interruption = guidance;
|
|
428
|
+
live.wake?.();
|
|
429
|
+
await live.session.abort();
|
|
430
|
+
return guidance.length ? 'continued' : 'paused';
|
|
431
|
+
}
|
|
432
|
+
/** Takes the user's newest undelivered message back off the queue, to edit it; undefined when there is none left to take. */
|
|
433
|
+
withdraw(id: string) {
|
|
434
|
+
const live = this.running.get(id);
|
|
435
|
+
const text = live?.info.guidance.findLast(g => g.state === 'queued' && g.from === 'user')?.text;
|
|
436
|
+
if (!live || text === undefined || live.requeueing || live.interruption) return undefined;
|
|
437
|
+
// The queue holds only texts, so of equal ones the last is taken back, record and queue entry alike.
|
|
438
|
+
const guidance = live.info.guidance.findLast(g => g.state === 'queued' && g.text === text)!;
|
|
439
|
+
const pending = live.pendingGuidance.lastIndexOf(guidance.text);
|
|
440
|
+
if (pending >= 0) live.pendingGuidance.splice(pending, 1);
|
|
441
|
+
else {
|
|
442
|
+
const steering = live.session.getSteeringMessages();
|
|
443
|
+
const at = steering.lastIndexOf(guidance.text);
|
|
444
|
+
if (at < 0) return undefined; // Already on its way to the agent.
|
|
445
|
+
// Pi cannot drop a single queued message: clear the queue and steer the rest back in order.
|
|
446
|
+
void this.requeue(live, live.session.clearQueue().steering.filter((_, i) => i !== at));
|
|
447
|
+
}
|
|
448
|
+
live.info.guidance.splice(live.info.guidance.indexOf(guidance), 1); this.save(live.info);
|
|
449
|
+
return guidance.text;
|
|
450
|
+
}
|
|
451
|
+
private requeue(live: LiveRun, texts: string[]) {
|
|
452
|
+
const work: Promise<void> = (async () => {
|
|
453
|
+
try { for (const text of texts) await live.session.steer(text); }
|
|
454
|
+
catch (error) { this.warn(`Could not requeue a message for ${live.info.id}: ${String(error)}`); }
|
|
455
|
+
})().finally(() => { if (live.requeueing === work) live.requeueing = undefined; });
|
|
456
|
+
live.requeueing = work;
|
|
457
|
+
return work;
|
|
458
|
+
}
|
|
374
459
|
/** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
|
|
375
460
|
private async resume(id: string, message: string, caller: string | undefined) {
|
|
376
461
|
const run = this.history.records.get(id)!, text = message.trim();
|
|
@@ -380,7 +465,7 @@ export class Children {
|
|
|
380
465
|
if (run.parentId !== caller) {
|
|
381
466
|
if (!run.parentId) throw new Error(`Only the main agent can resume ${id}.`);
|
|
382
467
|
const parent = this.running.get(run.parentId);
|
|
383
|
-
throw new Error(parent && ['running', 'waiting'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
468
|
+
throw new Error(parent && ['running', 'waiting', 'paused'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
384
469
|
: `Only ${id}'s parent ${run.parentId} can resume it, and that parent is no longer running. Resume ${run.parentId} instead, or spawn a new subagent.`);
|
|
385
470
|
}
|
|
386
471
|
if (this.closing) throw new Error('Profile is closing.');
|
|
@@ -399,7 +484,7 @@ export class Children {
|
|
|
399
484
|
const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
|
|
400
485
|
// stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
|
|
401
486
|
const parent = run.parentId ? this.running.get(run.parentId) : undefined;
|
|
402
|
-
if (this.closing || run.parentId && (!parent || !['running', 'waiting'].includes(parent.info.state))) {
|
|
487
|
+
if (this.closing || run.parentId && (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state))) {
|
|
403
488
|
await this.shutdown(session); this.dispose(session); throw new Error('Parent or profile is stopping.');
|
|
404
489
|
}
|
|
405
490
|
// The finished record stays untouched (and resumable) unless the new one is saved.
|
package/src/index.ts
CHANGED
|
@@ -176,7 +176,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
176
176
|
atomicWrite(pending, JSON.stringify(reports));
|
|
177
177
|
rememberProfile(name);
|
|
178
178
|
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
|
|
179
|
-
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
|
|
179
|
+
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
|
|
180
180
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
181
181
|
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
182
182
|
status(ctx);
|
|
@@ -381,7 +381,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
381
381
|
if (importing) throw new Error('Close the import dialog before opening the inspector.');
|
|
382
382
|
inspectorController = controller;
|
|
383
383
|
const signal = inspectorController.signal;
|
|
384
|
-
const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, page, signal,
|
|
384
|
+
const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, memory: a.memory, page, signal,
|
|
385
385
|
refreshUsage: () => { collectUsage(ctx); try { a.children.collectUsage(); } catch (error) { ctx.ui.notify(`Could not save child usage: ${errorText(error)}`, 'error'); } },
|
|
386
386
|
});
|
|
387
387
|
if (signal.aborted) return;
|
|
@@ -390,7 +390,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
390
390
|
} catch (error) { ctx.ui.notify(errorText(error), 'error'); }
|
|
391
391
|
finally { if (inspectorController === controller) inspectorController = undefined; }
|
|
392
392
|
};
|
|
393
|
-
pi.registerShortcut(shortcut, { description: 'Inspect OptChat agents and
|
|
393
|
+
pi.registerShortcut(shortcut, { description: 'Inspect OptChat agents, usage and background activity', handler: ctx => inspect(ctx, 'agents') });
|
|
394
394
|
const command = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
395
395
|
if (remote) { ctx.ui.notify('Manage this profile in its original window. Here use /tell-main or /complete.', 'info'); return; }
|
|
396
396
|
if (importing) throw new Error('Close the import dialog before changing profile settings.');
|
|
@@ -398,7 +398,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
398
398
|
if (!action) {
|
|
399
399
|
const a = active;
|
|
400
400
|
const info = a ? `${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending\nCompactor: ${a.config.compactor.model} (${a.config.compactor.thinking})\nAgents: ${a.config.subagent.model} (${a.config.subagent.thinking})\n${a.memory.lastError ?? ''}` : 'No active profile';
|
|
401
|
-
action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'instructions', 'browse', 'import']) ?? '';
|
|
401
|
+
action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import']) ?? '';
|
|
402
402
|
}
|
|
403
403
|
if (action === 'import') {
|
|
404
404
|
const a = required();
|
|
@@ -456,7 +456,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
456
456
|
}
|
|
457
457
|
if (action === 'model') return pickModel(ctx, 'compactor');
|
|
458
458
|
if (action === 'agents model') return pickModel(ctx, 'subagent');
|
|
459
|
-
if (action === 'agents' || action === 'usage') return inspect(ctx, action);
|
|
459
|
+
if (action === 'agents' || action === 'usage' || action === 'activity') return inspect(ctx, action);
|
|
460
460
|
if (action === 'instructions') {
|
|
461
461
|
const a = required();
|
|
462
462
|
const edited = await ctx.ui.editor(`${a.name} · AGENTS.md`, instructions(a.dir));
|
|
@@ -468,7 +468,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
468
468
|
if (ctx.hasUI) execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error) ctx.ui.notify(`Open ${file}`, 'info'); });
|
|
469
469
|
ctx.ui.notify(file, 'info'); return;
|
|
470
470
|
}
|
|
471
|
-
if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|instructions|browse|import].');
|
|
471
|
+
if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|activity|instructions|browse|import].');
|
|
472
472
|
};
|
|
473
473
|
pi.registerCommand('complete', { description: 'End this connected conversation and hand off to the main agent', handler: async (_args, ctx) => {
|
|
474
474
|
if (!remote) { ctx.ui.notify('/complete is for connected subagent windows.', 'info'); return; }
|
|
@@ -479,7 +479,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
479
479
|
try { await remote.tell(args); } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
|
|
480
480
|
} });
|
|
481
481
|
pi.registerCommand('optchat', { description: 'OptChat profiles, settings, models, agents, instructions, memory browser, and imports',
|
|
482
|
-
getArgumentCompletions: prefix => ['profile', 'settings', 'model', 'agents', 'agents model', 'usage', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
|
|
482
|
+
getArgumentCompletions: prefix => ['profile', 'settings', 'model', 'agents', 'agents model', 'usage', 'activity', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
|
|
483
483
|
handler: async (args, ctx) => { try { await command(args, ctx); } catch (error) { ctx.ui.notify(errorText(error), 'error'); } },
|
|
484
484
|
});
|
|
485
485
|
}
|
package/src/inspector.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
|
-
import {
|
|
4
|
+
import type { Memory } from './memory.ts';
|
|
5
|
+
import { isActiveRun, isRunning } from './runs.ts';
|
|
5
6
|
import type { Usage } from '@earendil-works/pi-ai';
|
|
6
7
|
import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
|
|
7
8
|
|
|
@@ -32,12 +33,16 @@ const spread = (left: string, right: string, width: number) => {
|
|
|
32
33
|
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
33
34
|
return gap >= 2 ? `${left}${' '.repeat(gap)}${right}` : left;
|
|
34
35
|
};
|
|
35
|
-
|
|
36
|
+
const pages = ['agents', 'usage', 'activity'] as const;
|
|
37
|
+
export type InspectorPage = typeof pages[number];
|
|
38
|
+
const pageNames: Record<InspectorPage, string> = { agents: 'Agents', usage: 'Usage', activity: 'Activity' };
|
|
39
|
+
/** Tab order, wrapping around: Agents, Usage, Activity. */
|
|
40
|
+
export const nextPage = (page: InspectorPage, delta = 1) => pages[(pages.indexOf(page) + delta + pages.length) % pages.length];
|
|
36
41
|
/** Closing the panel either picks the subagent model or opens one agent's conversation. */
|
|
37
42
|
export type InspectorAction = 'model' | { open: string };
|
|
38
43
|
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'border';
|
|
39
44
|
interface Options {
|
|
40
|
-
profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
|
|
45
|
+
profile: string; session: string; children: Children; usage: UsageLedger; memory: Pick<Memory, 'root' | 'size' | 'budget' | 'lastError' | 'progress' | 'onChange'>; page: InspectorPage;
|
|
41
46
|
rows: () => number; redraw: () => void; done: (action?: InspectorAction) => void;
|
|
42
47
|
color: (tone: Tone, text: string) => string;
|
|
43
48
|
context: () => number | null | undefined;
|
|
@@ -56,12 +61,12 @@ export class Inspector implements Component, Focusable {
|
|
|
56
61
|
private hintLines = 1;
|
|
57
62
|
private range: UsageRange = 'This session';
|
|
58
63
|
private ended = false;
|
|
59
|
-
private readonly unsubscribe: () => void;
|
|
64
|
+
private readonly unsubscribe: (() => void)[];
|
|
60
65
|
private readonly timer: ReturnType<typeof setInterval>;
|
|
61
66
|
constructor(private readonly options: Options) {
|
|
62
67
|
this.page = options.page;
|
|
63
68
|
this.selected = options.children.history.list()[0]?.id;
|
|
64
|
-
this.unsubscribe = options.children.subscribe(() => options.redraw());
|
|
69
|
+
this.unsubscribe = [options.children.subscribe(() => options.redraw()), options.memory.onChange(() => options.redraw())];
|
|
65
70
|
this.timer = setInterval(() => { options.refreshUsage?.(); options.redraw(); }, 1000); this.timer.unref();
|
|
66
71
|
options.signal?.addEventListener('abort', this.abort, { once: true });
|
|
67
72
|
if (options.signal?.aborted) queueMicrotask(this.abort);
|
|
@@ -69,7 +74,7 @@ export class Inspector implements Component, Focusable {
|
|
|
69
74
|
private readonly abort = () => this.finish();
|
|
70
75
|
private finish(action?: InspectorAction) { if (!this.ended) { this.dispose(); this.options.done(action); } }
|
|
71
76
|
dispose() {
|
|
72
|
-
this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.abort);
|
|
77
|
+
this.ended = true; clearInterval(this.timer); this.unsubscribe.forEach(stop => stop()); this.options.signal?.removeEventListener('abort', this.abort);
|
|
73
78
|
}
|
|
74
79
|
invalidate() {}
|
|
75
80
|
/** Body rows left after the rules, title, hint and spacing; the rest of the screen keeps the footer visible. */
|
|
@@ -82,9 +87,10 @@ export class Inspector implements Component, Focusable {
|
|
|
82
87
|
handleInput(data: string) {
|
|
83
88
|
if (this.ended) return;
|
|
84
89
|
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish();
|
|
85
|
-
if (
|
|
86
|
-
this.page = this.page
|
|
87
|
-
} else if (this.page === '
|
|
90
|
+
if (matchesKey(data, 'tab')) {
|
|
91
|
+
this.page = nextPage(this.page); this.scroll = 0;
|
|
92
|
+
} else if (this.page === 'activity') this.scrollInput(data);
|
|
93
|
+
else if (this.page === 'usage') {
|
|
88
94
|
if (matchesKey(data, 'left') || matchesKey(data, 'right')) {
|
|
89
95
|
const delta = matchesKey(data, 'left') ? -1 : 1;
|
|
90
96
|
this.range = ranges[(ranges.indexOf(this.range) + delta + ranges.length) % ranges.length]; this.scroll = 0;
|
|
@@ -137,17 +143,29 @@ export class Inspector implements Component, Focusable {
|
|
|
137
143
|
lines.push('', color('dim', 'Estimated at API prices, not your subscription bill.'), ...usage.warnings.map(w => color('warning', w)));
|
|
138
144
|
return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
|
|
139
145
|
}
|
|
146
|
+
/** Memory as a gauge: how full the view is, whether summaries are catching up, and how many agents run (their list is one Tab away). */
|
|
147
|
+
private activityLines(width: number) {
|
|
148
|
+
const { memory, children, color } = this.options;
|
|
149
|
+
const { done, total, retryIn } = memory.progress();
|
|
150
|
+
const list = children.history.list(), agents = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
|
|
151
|
+
const filled = total ? Math.floor(10 * done / total) : 0;
|
|
152
|
+
const lines = [`${color('muted', 'Memory')} · ${count(memory.root.length)} messages · view ${Math.round(memory.size / 1000)} KB / ${Math.round(memory.budget / 1000)} KB`,
|
|
153
|
+
total ? `Catching up · ${count(done)} of ${count(total)} summaries ${color('accent', '█'.repeat(filled))}${color('dim', '░'.repeat(10 - filled))}` : color('dim', 'Settled')];
|
|
154
|
+
if (memory.lastError) lines.push(color('dim', `${oneLine(memory.lastError)}${retryIn === undefined ? '' : ` · retry in ${elapsed(retryIn)}`}`));
|
|
155
|
+
lines.push('', `${color('muted', 'Agents')} · ${agents} running${paused ? ` · ${paused} interrupted` : ''}`);
|
|
156
|
+
return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
|
|
157
|
+
}
|
|
140
158
|
render(width: number): string[] {
|
|
141
159
|
const { color, children, profile } = this.options;
|
|
142
160
|
const inner = Math.max(1, width - 2);
|
|
143
|
-
let title = `OptChat · ${profile} · ${this.page
|
|
161
|
+
let title = `OptChat · ${profile} · ${pageNames[this.page]}`;
|
|
144
162
|
let info: string, body: string[], hint: string;
|
|
145
|
-
if (this.page
|
|
146
|
-
const lines = this.usageLines(inner); this.lineCount = lines.length;
|
|
163
|
+
if (this.page !== 'agents') {
|
|
164
|
+
const lines = this.page === 'usage' ? this.usageLines(inner) : this.activityLines(inner); this.lineCount = lines.length;
|
|
147
165
|
this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
|
|
148
166
|
body = lines.slice(this.scroll, this.scroll + this.height);
|
|
149
|
-
info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}
|
|
150
|
-
hint = '←→ period · ↑↓ scroll · Tab agents · Esc close';
|
|
167
|
+
info = this.page === 'usage' || lines.length > this.height ? `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}` : '';
|
|
168
|
+
hint = this.page === 'usage' ? '←→ period · ↑↓ scroll · Tab activity · Esc close' : '↑↓ scroll · Tab agents · Esc close';
|
|
151
169
|
} else {
|
|
152
170
|
const list = children.history.list();
|
|
153
171
|
if (!this.selected) this.selected = list[0]?.id;
|
|
@@ -157,7 +175,7 @@ export class Inspector implements Component, Focusable {
|
|
|
157
175
|
if (cursor >= this.top + this.height) this.top = cursor - this.height + 1;
|
|
158
176
|
const rows = list.slice(this.top, this.top + this.height).map(run => {
|
|
159
177
|
const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
|
|
160
|
-
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : run.state;
|
|
178
|
+
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : run.state;
|
|
161
179
|
return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
|
|
162
180
|
});
|
|
163
181
|
// Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
|
package/src/memory.ts
CHANGED
|
@@ -89,6 +89,8 @@ export class Memory {
|
|
|
89
89
|
/** Per level, every node below this index is built. */
|
|
90
90
|
private readonly low: number[] = [];
|
|
91
91
|
private retryTimer?: ReturnType<typeof setTimeout>;
|
|
92
|
+
/** The most summaries owed at once since none were last owed. */
|
|
93
|
+
private peak = 0;
|
|
92
94
|
private scheduled = false;
|
|
93
95
|
private stopped = false;
|
|
94
96
|
lastError?: string;
|
|
@@ -134,6 +136,13 @@ export class Memory {
|
|
|
134
136
|
get pending() { return this.root.length - this.leaves; }
|
|
135
137
|
get active() { return this.busy.size; }
|
|
136
138
|
get size() { return this.viewBytes; }
|
|
139
|
+
/** Summaries built out of the backlog since it was last empty, and when the next failed one is retried. */
|
|
140
|
+
progress(now = Date.now()) {
|
|
141
|
+
const due = Math.min(...[...this.retryAt.values()].filter(t => t > now));
|
|
142
|
+
return { done: this.peak - this.owed(), total: this.peak, retryIn: Number.isFinite(due) ? due - now : undefined };
|
|
143
|
+
}
|
|
144
|
+
private owed() { return this.expectedNodes() - this.tree.size; }
|
|
145
|
+
onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
|
|
137
146
|
/** The view line covering message `at`. The view tiles the log in order, so a binary search finds it. */
|
|
138
147
|
covering(at: number): Part | undefined {
|
|
139
148
|
for (let lo = 0, hi = this.view.length - 1; lo <= hi;) {
|
|
@@ -159,6 +168,8 @@ export class Memory {
|
|
|
159
168
|
this.viewBytes += this.partBytes(parent) - this.partBytes(a) - this.partBytes(b);
|
|
160
169
|
this.view.splice(best, 2, parent);
|
|
161
170
|
}
|
|
171
|
+
const owed = this.owed();
|
|
172
|
+
this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
|
|
162
173
|
this.events.emit('change');
|
|
163
174
|
}
|
|
164
175
|
private schedule() {
|
package/src/navigation.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { matchesKey, truncateToWidth, visibleWidth, type KeyId } from '@earendil-works/pi-tui';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import type { Memory } from './memory.ts';
|
|
5
|
+
import { inspectorShowing, nextPage, type InspectorPage } from './inspector.ts';
|
|
6
|
+
import { isRunning } from './runs.ts';
|
|
6
7
|
|
|
7
8
|
/** Configured before extension registration; this also works in legacy terminals with F6. */
|
|
8
9
|
export function inspectorShortcut(value = process.env.OPTCHAT_INSPECT_KEY ?? 'f6'): KeyId {
|
|
@@ -22,7 +23,7 @@ export class BarNavigation {
|
|
|
22
23
|
}
|
|
23
24
|
if (matchesKey(data, 'escape') || matchesKey(data, 'up')) { this.selected = undefined; return true; }
|
|
24
25
|
if (matchesKey(data, 'left') || matchesKey(data, 'right') || matchesKey(data, 'tab')) {
|
|
25
|
-
this.selected = this.selected
|
|
26
|
+
this.selected = nextPage(this.selected, matchesKey(data, 'left') ? -1 : 1); return true;
|
|
26
27
|
}
|
|
27
28
|
if (matchesKey(data, 'return')) { const page = this.selected; this.selected = undefined; open(page); return true; }
|
|
28
29
|
if (matchesKey(data, 'down')) return true;
|
|
@@ -30,7 +31,7 @@ export class BarNavigation {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function mountNavigation(ctx: ExtensionContext, children: Children, shortcut: string, open: (page: InspectorPage) => void) {
|
|
34
|
+
export function mountNavigation(ctx: ExtensionContext, children: Children, memory: Pick<Memory, 'progress' | 'onChange'>, shortcut: string, open: (page: InspectorPage) => void) {
|
|
34
35
|
const navigation = new BarNavigation();
|
|
35
36
|
const previous = ctx.ui.getEditorComponent();
|
|
36
37
|
let redraw = () => {};
|
|
@@ -51,18 +52,18 @@ export function mountNavigation(ctx: ExtensionContext, children: Children, short
|
|
|
51
52
|
invalidate() {},
|
|
52
53
|
render(width: number) {
|
|
53
54
|
if (inspectorShowing()) return []; // The open panel replaces the editor and this bar.
|
|
54
|
-
const list = children.history.list(), running = list.filter(
|
|
55
|
+
const list = children.history.list(), running = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
|
|
55
56
|
const label = (page: InspectorPage, text: string) => navigation.selected === page ? theme.fg('accent', `› ${text}`) : theme.fg('muted', text);
|
|
56
|
-
const left = `${label('agents', `Agents: ${running} running · ${list.length - running} saved`)} ${label('usage', 'Usage')}`;
|
|
57
|
+
const left = `${label('agents', `Agents: ${running} running${paused ? ` · ${paused} interrupted` : ''} · ${list.length - running - paused} saved`)} ${label('usage', 'Usage')} ${memory.progress().total || running ? theme.fg('accent', '● ') : ''}${label('activity', 'Activity')}`;
|
|
57
58
|
const hint = theme.fg('dim', navigation.selected ? '←→ select · Enter open · Esc input' : `${previous ? '' : '↓ select · '}${shortcut} inspect`);
|
|
58
59
|
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
59
60
|
return [truncateToWidth(gap >= 3 ? `${left}${' '.repeat(gap)}${hint}` : `${left} ${hint}`, width)];
|
|
60
61
|
},
|
|
61
62
|
};
|
|
62
63
|
}, { placement: 'belowEditor' });
|
|
63
|
-
const unsubscribe = children.subscribe(() => redraw());
|
|
64
|
+
const unsubscribe = [children.subscribe(() => redraw()), memory.onChange(() => redraw())];
|
|
64
65
|
return () => {
|
|
65
|
-
unsubscribe(); ctx.ui.setWidget('optchat-agents', undefined);
|
|
66
|
+
unsubscribe.forEach(stop => stop()); ctx.ui.setWidget('optchat-agents', undefined);
|
|
66
67
|
if (!previous && ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(undefined);
|
|
67
68
|
};
|
|
68
69
|
}
|
package/src/runs.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { atomicWrite } from './profiles.ts';
|
|
|
6
6
|
import { record } from './cache.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** `paused`: interrupted by the user with nothing queued, alive and waiting for its next message. `interrupted` is a finished run cut off by Pi closing or a connected window ending. */
|
|
10
|
+
export const runStates = ['running', 'waiting', 'stopping', 'completed', 'failed', 'stopped', 'interrupted', 'paused'] as const;
|
|
10
11
|
export type RunState = typeof runStates[number];
|
|
11
12
|
export type FinishReason = 'complete' | 'disconnected' | 'owner-stopped' | 'failed';
|
|
12
13
|
export interface RunInfo {
|
|
@@ -20,8 +21,9 @@ export interface RunInfo {
|
|
|
20
21
|
guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
|
|
21
22
|
}
|
|
22
23
|
const moves: Record<RunState, readonly RunState[]> = {
|
|
23
|
-
running: ['waiting', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
24
|
-
waiting: ['running', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
24
|
+
running: ['waiting', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
25
|
+
waiting: ['running', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
26
|
+
paused: ['running', 'stopping', 'failed', 'stopped', 'interrupted'],
|
|
25
27
|
stopping: ['stopped', 'failed', 'interrupted'],
|
|
26
28
|
// A finished run only takes the outcome of a connected handoff.
|
|
27
29
|
completed: ['interrupted'], failed: ['completed', 'interrupted'], stopped: ['completed', 'interrupted'], interrupted: ['completed'],
|
|
@@ -31,7 +33,9 @@ export function transition(run: RunInfo, to: RunState) {
|
|
|
31
33
|
if (run.state !== to && !moves[run.state].includes(to)) return false;
|
|
32
34
|
run.state = to; return true;
|
|
33
35
|
}
|
|
34
|
-
export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping';
|
|
36
|
+
export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping' || run.state === 'paused';
|
|
37
|
+
/** Live and working: a paused run is neither running nor finished. */
|
|
38
|
+
export const isRunning = (run: RunInfo) => isActiveRun(run) && run.state !== 'paused';
|
|
35
39
|
function isRun(value: unknown): value is RunInfo {
|
|
36
40
|
return record(value) && ['id', 'task', 'cwd', 'model', 'thinking', 'parentSession'].every(k => typeof value[k] === 'string')
|
|
37
41
|
&& typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
|