pi-optchat 0.5.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/src/index.ts ADDED
@@ -0,0 +1,395 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { hostname } from 'node:os';
5
+ import { createHash } from 'node:crypto';
6
+ import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
7
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
+ import { Type } from 'typebox';
9
+ import { Memory } from './memory.ts';
10
+ import { createCompressor } from './compactor.ts';
11
+ import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
+ import { MASTER, VIEW_DOC } from './prompts.ts';
13
+ import { cachePayload, record } from './cache.ts';
14
+ import { boundedMessage, buildContext, logMessage, previousExchange, RUN_BOUNDARY, textContent } from './transcript.ts';
15
+ import { memoryTools, result } from './tools.ts';
16
+ import { Children } from './agents.ts';
17
+ import { exportBrowser } from './browser.ts';
18
+ import { Inbox } from './inbox.ts';
19
+ import { checkpoint } from './checkpoint.ts';
20
+ import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
21
+ import { chooseImport, showProgress } from './import/ui.ts';
22
+ import { IMPORT_GUIDANCE } from './import/guidance.ts';
23
+ import { UsageLedger } from './usage.ts';
24
+ import { showInspector, type InspectorPage } from './inspector.ts';
25
+ import { inspectorShortcut, mountNavigation } from './navigation.ts';
26
+ import { serveWindows } from './window-bridge.ts';
27
+ import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
28
+ import { createHandoffSummarizer } from './handoff.ts';
29
+
30
+ const binding = 'optchat.profile';
31
+ interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
32
+
33
+ export default function optchat(pi: ExtensionAPI) {
34
+ let active: Active | undefined;
35
+ let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
36
+ let closeWindows: (() => Promise<void>) | undefined;
37
+ let recovery: Promise<void> | undefined;
38
+ let run: AgentMessage[] = [];
39
+ let previous: AgentMessage[] = [];
40
+ let logged = 0;
41
+ let view: string | undefined;
42
+ let prompt = '';
43
+ let runStarted = false;
44
+ let fault: string | undefined;
45
+ let reports: string[] = [];
46
+ const receipts = new Map<AgentMessage, string>();
47
+ let checkpoints = Promise.resolve();
48
+ let importing = false;
49
+ let stopping = false;
50
+ let importController: AbortController | undefined;
51
+ let importTask: Promise<void> | undefined;
52
+ let unmountNavigation: (() => void) | undefined;
53
+ let inspectorController: AbortController | undefined;
54
+ const shortcut = inspectorShortcut();
55
+ const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
56
+ const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
57
+ pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
58
+ const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
59
+ const status = (ctx: ExtensionContext) => {
60
+ const a = active;
61
+ ctx.ui.setStatus('optchat', a ? `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${importing ? ' · importing' : pendingImport(a.dir) ? ' · import paused: /optchat import' : ''}` : 'OptChat: choose profile');
62
+ };
63
+ const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports)); };
64
+ const flush = () => {
65
+ if (!active) return;
66
+ while (logged < run.length) {
67
+ const message = run[logged];
68
+ const receipt = receipts.get(message);
69
+ logMessage(active.memory, message, receipt); logged++;
70
+ if (receipt && !receipt.startsWith('report:')) active.inbox.acknowledge(receipt);
71
+ receipts.delete(message);
72
+ if (message.role === 'user') {
73
+ const index = reports.indexOf(textContent(message.content));
74
+ if (index >= 0) { reports.splice(index, 1); saveReports(); }
75
+ }
76
+ }
77
+ };
78
+ const deliverReport = async (text: string, once = false) => {
79
+ if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
80
+ reports.push(text); saveReports();
81
+ if (!stopping) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
82
+ };
83
+ const stop = async () => {
84
+ remote?.close(); remote = undefined;
85
+ inspectorController?.abort(); unmountNavigation?.(); unmountNavigation = undefined;
86
+ stopping = true; importController?.abort(); await importTask?.catch(() => {});
87
+ if (!active) return;
88
+ const old = active;
89
+ try {
90
+ await closeWindows?.(); closeWindows = undefined;
91
+ await recovery;
92
+ await old.children.close(); flush(); if (!pendingImport(old.dir)) old.inbox.recover(old.memory);
93
+ await old.memory.close(); await checkpoints;
94
+ await checkpoint(old.dir);
95
+ } finally {
96
+ await old.memory.close(); await old.unlock(); active = undefined;
97
+ run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear();
98
+ }
99
+ };
100
+ const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
101
+ if (!ctx.hasUI) return undefined;
102
+ const names = listProfiles(), last = lastProfile();
103
+ if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
104
+ const selected = await ctx.ui.select('OptChat profile', [...names, '+ Create profile']);
105
+ if (selected !== '+ Create profile') return selected;
106
+ const name = (await ctx.ui.input('New profile name', 'work or personal'))?.trim();
107
+ if (!name) return undefined;
108
+ createProfile(name); return name;
109
+ };
110
+ const openProfile = async (name: string, ctx: ExtensionContext) => {
111
+ const dir = profilePath(name);
112
+ if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
113
+ const unlock = await lockProfile(dir, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`);
114
+ let openingMemory: Memory | undefined;
115
+ try {
116
+ const config = loadConfig(dir);
117
+ const sessionId = ctx.sessionManager.getSessionId();
118
+ const usage = new UsageLedger(dir);
119
+ usage.backfill(ctx.sessionManager.getEntries(), sessionId);
120
+ const pending = join(dir, 'pending-reports.json');
121
+ const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
122
+ if (!Array.isArray(saved) || !saved.every(s => typeof s === 'string')) throw new Error('Invalid pending report journal.');
123
+ const memory = new Memory(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
124
+ usage.compression(message, 'compactor', sessionId);
125
+ status(ctx);
126
+ }), warning => ctx.ui.notify(warning, 'error'));
127
+ openingMemory = memory;
128
+ const inbox = new Inbox(dir);
129
+ const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
130
+ if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
131
+ const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
132
+ deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage,
133
+ summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
134
+ const loggedReports = new Set(memory.root.map(e => e.receipt));
135
+ reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
136
+ atomicWrite(pending, JSON.stringify(reports));
137
+ rememberProfile(name);
138
+ active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
139
+ if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
140
+ closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
141
+ status(ctx);
142
+ ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
143
+ const queuedReports = [...reports];
144
+ if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
145
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false }); });
146
+ } catch (error) {
147
+ await closeWindows?.(); closeWindows = undefined;
148
+ if (active && active.memory === openingMemory) {
149
+ unmountNavigation?.(); unmountNavigation = undefined;
150
+ await active.children.close(); active = undefined;
151
+ }
152
+ await openingMemory?.close();
153
+ await unlock(); throw error;
154
+ }
155
+ };
156
+
157
+ pi.on('session_start', async (_event, ctx) => {
158
+ stopping = false;
159
+ const saved = ctx.sessionManager.getEntries().find(e => e.type === 'custom' && e.customType === binding);
160
+ const boundName = saved?.type === 'custom' && record(saved.data) && typeof saved.data.name === 'string' ? saved.data.name : undefined;
161
+ const flag = pi.getFlag('optchat-profile');
162
+ try {
163
+ if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
164
+ const name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
165
+ if (!name) { status(ctx); return; }
166
+ try { await openProfile(name, ctx); }
167
+ catch (error) {
168
+ if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
169
+ if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
170
+ remote = await openConnectedWindow(pi, ctx, name); fault = undefined;
171
+ }
172
+ if (!boundName) pi.appendEntry(binding, { name });
173
+ } catch (error) {
174
+ if (active) await stop().catch(() => {});
175
+ fault = errorText(error); ctx.ui.notify(fault, 'error');
176
+ }
177
+ });
178
+ pi.on('session_shutdown', stop);
179
+ pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
180
+ pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
181
+ pi.on('input', async (event, ctx) => {
182
+ if (remote) {
183
+ try {
184
+ if (event.images?.length) throw new Error('Connected windows currently accept text only; provide a file path for the agent to read.');
185
+ await remote.submit(event.text);
186
+ } catch (error) { ctx.ui.notify(errorText(error), 'error'); ctx.ui.setEditorText(event.text); }
187
+ return { action: 'handled' };
188
+ }
189
+ if (!active) { ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' }; }
190
+ if (importing || pendingImport(active.dir)) { ctx.ui.notify('This profile has an import in progress. Use /optchat import to resume or discard it, or switch profiles.', 'info'); return { action: 'handled' }; }
191
+ if (event.source !== 'extension') {
192
+ try { active.inbox.record(event.text); }
193
+ catch (error) { ctx.ui.notify(`Could not save input: ${errorText(error)}`, 'error'); return { action: 'handled' }; }
194
+ }
195
+ return { action: 'continue' };
196
+ });
197
+ pi.on('before_agent_start', (_event, ctx) => {
198
+ flush(); run = []; logged = 0; view = undefined; runStarted = true;
199
+ previous = previousExchange(ctx.sessionManager.getBranch());
200
+ pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
201
+ const a = required();
202
+ prompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.\n\n${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}\n\nWorking directory: ${ctx.cwd}`;
203
+ });
204
+ pi.on('message_end', (event, ctx) => {
205
+ if (!active || !runStarted) return;
206
+ const message = boundedMessage(event.message);
207
+ if (message.role === 'user') {
208
+ try {
209
+ const text = textContent(message.content);
210
+ if (reports.includes(text)) receipts.set(message, reportReceipt(text));
211
+ else {
212
+ let receipt = active.inbox.claim(text);
213
+ if (!receipt) { active.inbox.record(text); receipt = active.inbox.claim(text); }
214
+ if (receipt) receipts.set(message, receipt);
215
+ }
216
+ } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
217
+ }
218
+ run.push(message);
219
+ if (view !== undefined) {
220
+ try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
221
+ }
222
+ if (message !== event.message) return { message };
223
+ });
224
+ pi.on('context_with_system', async (event, ctx) => {
225
+ try {
226
+ const a = required();
227
+ if (importing || pendingImport(a.dir)) throw new Error('Profile is unavailable while importing.');
228
+ if (fault) throw new Error(fault);
229
+ if (view === undefined) {
230
+ ctx.ui.setWorkingMessage('Waiting for OptChat summaries…');
231
+ await a.memory.settle(ctx.signal);
232
+ view = a.memory.render(); // Capture old history before logging the new input.
233
+ flush(); ctx.ui.setWorkingMessage();
234
+ }
235
+ return { messages: buildContext(event.messages, run, view, prompt, previous) };
236
+ } catch (error) {
237
+ // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
238
+ ctx.abort();
239
+ try { flush(); } catch (persistenceError) { fault = errorText(persistenceError); }
240
+ ctx.ui.notify(errorText(error), 'error');
241
+ return { messages: [{ role: 'system', content: 'OptChat context unavailable. Stop.', timestamp: 0 }] };
242
+ }
243
+ });
244
+ pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
245
+ pi.on('cache_warming_decision', () => ({ action: 'stop' }));
246
+ pi.on('session_before_compact', (_event, ctx) => {
247
+ ctx.ui.notify('OptChat manages history between turns. Pi compaction is disabled; an exceptionally long single run may require stopping and continuing in a new turn.', 'info');
248
+ return { cancel: true };
249
+ });
250
+ const collectUsage = (ctx: ExtensionContext) => {
251
+ try { active?.usage.backfill(ctx.sessionManager.getEntries(), ctx.sessionManager.getSessionId()); }
252
+ catch (error) { ctx.ui.notify(`Could not save usage: ${errorText(error)}`, 'error'); }
253
+ };
254
+ pi.on('turn_end', (_event, ctx) => collectUsage(ctx));
255
+ pi.on('agent_settled', async (_event, ctx) => {
256
+ collectUsage(ctx);
257
+ try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
258
+ if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
259
+ runStarted = false; status(ctx);
260
+ if (active) {
261
+ const dir = active.dir;
262
+ checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
263
+ await checkpoints;
264
+ }
265
+ });
266
+ registerConnectedRenderer(pi);
267
+ for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
268
+ pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
269
+ description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
270
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
271
+ async execute(_id, args, signal, _update, ctx) {
272
+ const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
273
+ return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
274
+ },
275
+ });
276
+ pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a running subagent at its next tool boundary.',
277
+ parameters: Type.Object({ id: Type.String(), message: Type.String() }),
278
+ async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
279
+ });
280
+
281
+ const pickModel = async (ctx: ExtensionContext, role: 'compactor' | 'subagent') => {
282
+ const a = required(), current = a.config[role];
283
+ const choices = ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
284
+ choices.sort((a, b) => Number(b === `${current.provider}/${current.model}`) - Number(a === `${current.provider}/${current.model}`) || a.localeCompare(b));
285
+ const selected = await ctx.ui.select(`${a.name}: ${role} model`, choices);
286
+ if (!selected) return;
287
+ const thinking = await ctx.ui.select('Thinking level', ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
288
+ if (!thinking) return;
289
+ const separator = selected.indexOf('/');
290
+ a.config[role] = { provider: selected.slice(0, separator), model: selected.slice(separator + 1), thinking: thinking as ThinkingLevel };
291
+ saveConfig(a.dir, a.config); ctx.ui.notify(`${role}: ${selected} (${thinking}); applies to new calls.`, 'info');
292
+ };
293
+ const inspect = async (ctx: ExtensionContext, page: InspectorPage) => {
294
+ if (inspectorController) return;
295
+ const controller = new AbortController();
296
+ try {
297
+ const a = required();
298
+ if (ctx.mode !== 'tui') throw new Error('The inspector requires interactive Pi.');
299
+ if (importing) throw new Error('Close the import dialog before opening the inspector.');
300
+ inspectorController = controller;
301
+ const signal = inspectorController.signal;
302
+ const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, page, signal,
303
+ refreshUsage: () => { collectUsage(ctx); try { a.children.collectUsage(); } catch (error) { ctx.ui.notify(`Could not save child usage: ${errorText(error)}`, 'error'); } },
304
+ });
305
+ if (action === 'model' && !signal.aborted) await pickModel(ctx, 'subagent');
306
+ } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
307
+ finally { if (inspectorController === controller) inspectorController = undefined; }
308
+ };
309
+ pi.registerShortcut(shortcut, { description: 'Inspect OptChat agents and usage', handler: ctx => inspect(ctx, 'agents') });
310
+ const command = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
311
+ if (remote) { ctx.ui.notify('Manage this profile in its original window. Here use /tell-main or /complete.', 'info'); return; }
312
+ if (importing) throw new Error('Close the import dialog before changing profile settings.');
313
+ let action = args.trim();
314
+ if (!action) {
315
+ const a = active;
316
+ 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';
317
+ action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'model', 'agents', 'usage', 'instructions', 'browse', 'import']) ?? '';
318
+ }
319
+ if (action === 'import') {
320
+ const a = required();
321
+ if (!ctx.hasUI) throw new Error('/optchat import requires interactive Pi.');
322
+ if (!ctx.isIdle() || a.children.active || reports.length) throw new Error('Finish active work and pending reports before importing.');
323
+ importing = true; importController = new AbortController(); let closed = false;
324
+ status(ctx);
325
+ const signal = importController.signal;
326
+ importTask = (async () => {
327
+ let job = pendingImport(a.dir);
328
+ if (job) {
329
+ const selected = await ctx.ui.select(`Paused ${job.mode} import · ${job.added} new messages`, ['Resume import', 'Discard staged import', 'Cancel'], { signal });
330
+ if (selected === 'Discard staged import') {
331
+ if (await ctx.ui.confirm('Discard import?', 'Original memory stays unchanged. Staged summaries will be removed.', { signal })) { signal.throwIfAborted(); discardImport(a.dir); }
332
+ return;
333
+ }
334
+ if (selected !== 'Resume import') return;
335
+ } else {
336
+ const plan = await chooseImport(ctx, a.name, a.memory, `${a.config.compactor.provider}/${a.config.compactor.model} (${a.config.compactor.thinking})`, signal);
337
+ if (!plan) return;
338
+ signal.throwIfAborted();
339
+ flush(); a.inbox.recover(a.memory); await checkpoints; await a.memory.close(); closed = true;
340
+ signal.throwIfAborted();
341
+ job = prepareImport(a.dir, a.memory, plan.entries, plan.mode);
342
+ if (!job) return;
343
+ }
344
+ if (!closed) { await a.memory.close(); closed = true; }
345
+ const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
346
+ a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
347
+ });
348
+ const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, progress), signal);
349
+ ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
350
+ : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
351
+ })();
352
+ let failure: unknown;
353
+ try { await importTask; } catch (error) { failure = error; }
354
+ finally { importing = false; importTask = undefined; importController = undefined; ctx.ui.setWidget('optchat-import', undefined); }
355
+ if (closed && !stopping) await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: a.name }); } });
356
+ else status(ctx);
357
+ if (failure) throw failure;
358
+ return;
359
+ }
360
+ if (action === 'profile') {
361
+ if (!ctx.isIdle() || active?.children.active) throw new Error('Finish or stop active work before switching profiles.');
362
+ const selected = await chooseProfile(ctx);
363
+ if (!selected || selected === active?.name) return;
364
+ await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: selected }); } });
365
+ return;
366
+ }
367
+ if (action === 'model') return pickModel(ctx, 'compactor');
368
+ if (action === 'agents model') return pickModel(ctx, 'subagent');
369
+ if (action === 'agents' || action === 'usage') return inspect(ctx, action);
370
+ if (action === 'instructions') {
371
+ const a = required();
372
+ const edited = await ctx.ui.editor(`${a.name} · AGENTS.md`, instructions(a.dir));
373
+ if (edited !== undefined) { atomicWrite(join(a.dir, 'AGENTS.md'), edited); ctx.ui.notify('Saved. Applies to the next turn and newly spawned agents.', 'info'); }
374
+ return;
375
+ }
376
+ if (action === 'browse') {
377
+ const a = required(), file = exportBrowser(a.memory, a.name, a.dir);
378
+ if (ctx.hasUI) execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error) ctx.ui.notify(`Open ${file}`, 'info'); });
379
+ ctx.ui.notify(file, 'info'); return;
380
+ }
381
+ if (action) throw new Error('Use /optchat [profile|model|agents|usage|instructions|browse|import].');
382
+ };
383
+ pi.registerCommand('complete', { description: 'End this connected conversation and hand off to the main agent', handler: async (_args, ctx) => {
384
+ if (!remote) { ctx.ui.notify('/complete is for connected subagent windows.', 'info'); return; }
385
+ try { await remote.complete(); } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
386
+ } });
387
+ pi.registerCommand('tell-main', { description: 'Send a message to the main agent from this connected window', handler: async (args, ctx) => {
388
+ if (!remote) { ctx.ui.notify('/tell-main is for connected subagent windows.', 'info'); return; }
389
+ try { await remote.tell(args); } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
390
+ } });
391
+ pi.registerCommand('optchat', { description: 'OptChat profiles, models, agents, instructions, memory browser, and imports',
392
+ getArgumentCompletions: prefix => ['profile', 'model', 'agents', 'agents model', 'usage', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
393
+ handler: async (args, ctx) => { try { await command(args, ctx); } catch (error) { ctx.ui.notify(errorText(error), 'error'); } },
394
+ });
395
+ }
@@ -0,0 +1,220 @@
1
+ import { Input, matchesKey, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
4
+ import type { Children } from './agents.ts';
5
+ import { isActiveRun, type RunInfo } from './runs.ts';
6
+ import { ranges, summarizeUsage, type UsageLedger, type UsageRange } from './usage.ts';
7
+ import { textContent } from './transcript.ts';
8
+ import { record } from './cache.ts';
9
+
10
+ export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
11
+ export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
12
+ export const count = (n: number) => n.toLocaleString('en-US');
13
+ export const elapsed = (ms: number) => ms < 60_000 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.floor(ms / 1000) % 60}s`;
14
+ export type InspectorPage = 'agents' | 'usage';
15
+ interface Options {
16
+ profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
17
+ rows: () => number; redraw: () => void; done: (action?: 'model') => void;
18
+ color: (tone: 'accent' | 'dim' | 'error', text: string) => string;
19
+ context: () => number | null | undefined;
20
+ refreshUsage?: () => void;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ /** UI projection only: inspecting never injects child transcripts into parent memory. */
25
+ export class Inspector implements Component, Focusable {
26
+ focused = true;
27
+ private page: InspectorPage;
28
+ private selected?: string;
29
+ private opened?: string;
30
+ private top = 0;
31
+ private scroll = 0;
32
+ private lineCount = 0;
33
+ private follow = true;
34
+ private details = false;
35
+ private range: UsageRange = 'This session';
36
+ private composer?: Input;
37
+ private error = '';
38
+ private busy = false;
39
+ private ended = false;
40
+ private savedMessages?: AgentMessage[];
41
+ private liveTranscript = false;
42
+ private readonly unsubscribe: () => void;
43
+ private readonly timer: ReturnType<typeof setInterval>;
44
+ constructor(private readonly options: Options) {
45
+ this.page = options.page;
46
+ this.selected = options.children.history.list()[0]?.id;
47
+ this.unsubscribe = options.children.subscribe(() => options.redraw());
48
+ this.timer = setInterval(() => { options.refreshUsage?.(); options.redraw(); }, 1000); this.timer.unref();
49
+ options.signal?.addEventListener('abort', this.abort, { once: true });
50
+ if (options.signal?.aborted) queueMicrotask(this.abort);
51
+ }
52
+ private readonly abort = () => this.finish();
53
+ private finish(action?: 'model') { if (!this.ended) { this.dispose(); this.options.done(action); } }
54
+ dispose() {
55
+ this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.abort);
56
+ }
57
+ invalidate() { this.composer?.invalidate(); }
58
+ private get height() { return Math.max(1, Math.floor(this.options.rows() * 0.9) - (this.composer ? 9 : 7)); }
59
+ private select(delta: number) {
60
+ const list = this.options.children.history.list();
61
+ const index = Math.max(0, list.findIndex(r => r.id === this.selected));
62
+ this.selected = list[Math.max(0, Math.min(list.length - 1, index + delta))]?.id;
63
+ }
64
+ private async act(action: () => Promise<unknown>) {
65
+ this.busy = true;
66
+ try { await action(); } catch (error) { this.error = String(error); }
67
+ finally { this.busy = false; if (!this.ended) this.options.redraw(); }
68
+ }
69
+ handleInput(data: string) {
70
+ if (this.ended) return;
71
+ this.error = '';
72
+ if (this.composer) {
73
+ if (matchesKey(data, 'escape')) this.composer = undefined;
74
+ else if (matchesKey(data, 'return') && !this.busy) {
75
+ const text = this.composer.getValue().trim(), id = this.opened;
76
+ if (text && id) { this.composer = undefined; void this.act(() => this.options.children.tell(id, text, 'user')); }
77
+ } else this.composer.handleInput(data);
78
+ } else if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
79
+ if (this.opened) { this.opened = undefined; this.savedMessages = undefined; this.scroll = 0; }
80
+ else return this.finish();
81
+ } else if (data === 'u' || matchesKey(data, 'tab')) {
82
+ this.page = this.page === 'agents' ? 'usage' : 'agents'; this.opened = undefined; this.scroll = 0; this.follow = true;
83
+ } else if (this.page === 'usage') {
84
+ if (matchesKey(data, 'left') || matchesKey(data, 'right')) {
85
+ const delta = matchesKey(data, 'left') ? -1 : 1;
86
+ this.range = ranges[(ranges.indexOf(this.range) + delta + ranges.length) % ranges.length]; this.scroll = 0;
87
+ } else this.scrollInput(data);
88
+ } else if (this.opened) {
89
+ if (data === 's' && !this.busy && ['running', 'waiting'].includes(this.options.children.live(this.opened)?.info.state ?? '')) {
90
+ this.composer = new Input({ prompt: 'Message: ', placeholder: 'guidance for this child' }); this.composer.focused = this.focused;
91
+ } else if (data === 'x' && !this.busy && this.options.children.live(this.opened)) {
92
+ const id = this.opened; void this.act(() => this.options.children.stop(id));
93
+ } else if (data === 't') this.details = !this.details;
94
+ else if (data === 'f' || matchesKey(data, 'end')) { this.follow = true; this.scroll = Math.max(0, this.lineCount - this.height); }
95
+ else this.scrollInput(data);
96
+ } else {
97
+ if (matchesKey(data, 'up')) this.select(-1);
98
+ else if (matchesKey(data, 'down')) this.select(1);
99
+ else if (matchesKey(data, 'pageUp')) this.select(-this.height);
100
+ else if (matchesKey(data, 'pageDown')) this.select(this.height);
101
+ else if (matchesKey(data, 'home')) this.select(-Infinity);
102
+ else if (matchesKey(data, 'end')) this.select(Infinity);
103
+ else if (data === 'm') return this.finish('model');
104
+ else if (matchesKey(data, 'return') && this.selected) {
105
+ try {
106
+ this.savedMessages = this.options.children.messages(this.selected); this.opened = this.selected;
107
+ this.liveTranscript = !!this.options.children.live(this.selected);
108
+ this.follow = true; this.scroll = 0;
109
+ } catch (error) { this.error = `Transcript unavailable: ${String(error)}`; }
110
+ }
111
+ }
112
+ this.options.redraw();
113
+ }
114
+ private scrollInput(data: string) {
115
+ const delta = matchesKey(data, 'up') ? -1 : matchesKey(data, 'down') ? 1 : matchesKey(data, 'pageUp') ? -this.height : matchesKey(data, 'pageDown') ? this.height : 0;
116
+ if (delta || matchesKey(data, 'home') || matchesKey(data, 'end')) {
117
+ this.follow = false;
118
+ this.scroll = matchesKey(data, 'home') ? 0 : matchesKey(data, 'end') ? this.lineCount : this.scroll + delta;
119
+ this.scroll = Math.max(0, Math.min(this.scroll, this.lineCount - this.height));
120
+ }
121
+ }
122
+ private transcript(run: RunInfo) {
123
+ const live = this.options.children.live(run.id);
124
+ if (live || this.liveTranscript) {
125
+ try { this.savedMessages = this.options.children.messages(run.id); }
126
+ catch (error) { this.error = `Transcript unavailable: ${String(error)}`; }
127
+ this.liveTranscript = !!live;
128
+ }
129
+ const messages = this.savedMessages ?? [];
130
+ const usage = summarizeUsage(this.options.usage.entries.filter(e => e.runId === run.id)).total;
131
+ const blocks = [`Task: ${run.task}`, `Directory: ${run.cwd}`, `Model: ${run.model} · effort ${run.thinking}`, `Depth: ${run.depth}/3 · parent: ${run.parentId ?? 'main agent'}`,
132
+ `Usage: ${count(usage.totalTokens)} tokens · ${count(usage.input)} input · ${count(usage.output)} output · ${count(usage.cacheRead)}/${count(usage.cacheWrite)} cache read/write · $${usage.cost.total.toFixed(4)} API-equivalent`];
133
+ let initialUser = true;
134
+ for (const message of messages) {
135
+ if (message.role === 'user') {
136
+ if (initialUser) { initialUser = false; continue; } // The initial memory view is context, not work output.
137
+ blocks.push(`You / manager: ${textContent(message.content)}`);
138
+ } else if (message.role === 'assistant') {
139
+ for (const part of message.content) {
140
+ if (part.type === 'text') blocks.push(part.text);
141
+ else if (part.type === 'toolCall') blocks.push(`Tool: ${part.name}${this.details ? `\n${JSON.stringify(part.arguments, null, 2)}` : ''}`);
142
+ }
143
+ if (message.errorMessage) blocks.push(`Error: ${message.errorMessage}`);
144
+ } else if (message.role === 'toolResult') {
145
+ const output = textContent(message.content);
146
+ blocks.push(`${message.isError ? 'Error' : 'Result'}: ${message.toolName}\n${this.details ? output : `${oneLine(output).slice(0, 160)}${output.length > 160 ? '… [t expands]' : ''}`}`);
147
+ }
148
+ }
149
+ for (const tool of live?.tools.values() ?? []) {
150
+ blocks.push(`Running: ${tool.name} · ${elapsed(Date.now() - tool.started)}\n${JSON.stringify(tool.args)}`);
151
+ if (tool.output) {
152
+ const output = record(tool.output) ? textContent(tool.output.content) : String(tool.output);
153
+ blocks.push(this.details ? output : output.slice(-500));
154
+ }
155
+ }
156
+ if (run.report && !live) blocks.push(`Final report: ${run.report}`);
157
+ for (const g of run.guidance) if (g.state !== 'delivered') blocks.push(`Message ${g.state}: ${g.text}`);
158
+ return blocks.filter(Boolean).map(clean).join('\n\n');
159
+ }
160
+ private usageLines() {
161
+ const { usage, session, context } = this.options;
162
+ const entries = usage.select(this.range, session);
163
+ const { total, groups } = summarizeUsage(entries);
164
+ const input = total.input + total.cacheRead + total.cacheWrite;
165
+ const lines = [`← → period: ${this.range}`, '', `Total processed: ${count(total.totalTokens)} tokens · ${entries.length} usage records`,
166
+ `Input: ${count(input)} · Output: ${count(total.output)}`,
167
+ `Cache read: ${count(total.cacheRead)} · Cache write: ${count(total.cacheWrite)} · Hit rate: ${input ? (100 * total.cacheRead / input).toFixed(1) : '0'}%`,
168
+ `Estimated API-equivalent cost: $${total.cost.total.toFixed(4)}`, '',
169
+ 'ROLE / MODEL — input · output · cache read/write · estimated cost'];
170
+ for (const g of groups) lines.push(`${g.role} / ${g.model}\n ${count(g.usage.input)} uncached input · ${count(g.usage.output)} out · ${count(g.usage.cacheRead)}/${count(g.usage.cacheWrite)} cache · $${g.usage.cost.total.toFixed(4)} · ${g.requests} records`);
171
+ const current = context();
172
+ lines.push('', `Current parent context (Pi estimate): ${current == null ? 'unavailable' : `${count(current)} tokens`} — separate from cumulative usage.`,
173
+ 'Costs use model API rates when available; not your subscription bill or remaining allowance.',
174
+ 'Usage updates when responses finish. Tool overhead may be a usage record without a request count.',
175
+ 'Main tracking starts with this version; the resumed parent session and saved children are backfilled.',
176
+ 'Older compactor/child records without a parent session appear only in date-based totals.', ...usage.warnings);
177
+ return lines.join('\n');
178
+ }
179
+ render(width: number): string[] {
180
+ const { color, children, profile } = this.options;
181
+ const inspected = this.opened ? children.history.records.get(this.opened) : undefined;
182
+ const header = color('accent', `OptChat · ${profile} · ${inspected ? `${inspected.state}: ${oneLine(inspected.task)}` : this.page === 'usage' ? 'Usage' : 'Agents'}`);
183
+ let body: string[], hint: string;
184
+ if (this.page === 'usage' || this.opened) {
185
+ const run = this.opened ? children.history.records.get(this.opened) : undefined;
186
+ const text = this.page === 'usage' ? this.usageLines() : run ? `${run.state} · ${elapsed((run.ended ?? Date.now()) - run.started)} · ${run.id}\n\n${this.transcript(run)}` : 'Run unavailable.';
187
+ const lines = wrapTextWithAnsi(clean(text), Math.max(1, width)); this.lineCount = lines.length;
188
+ if (this.opened && this.follow) this.scroll = Math.max(0, lines.length - this.height);
189
+ this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
190
+ body = lines.slice(this.scroll, this.scroll + this.height);
191
+ body.push(color('dim', `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}${this.opened ? this.follow ? ' · following' : ' · scroll paused (f follows)' : ''}`));
192
+ hint = this.page === 'usage' ? '←→ period · ↑↓/PgUp/PgDn scroll · Tab agents · Esc close' : '↑↓/PgUp/PgDn scroll · t tools · f follow · s message · x stop tree · Esc back';
193
+ } else {
194
+ const list = children.history.list();
195
+ if (!this.selected) this.selected = list[0]?.id;
196
+ const cursor = Math.max(0, list.findIndex(r => r.id === this.selected));
197
+ this.top = Math.max(0, Math.min(this.top, list.length - this.height));
198
+ if (cursor < this.top) this.top = cursor;
199
+ if (cursor >= this.top + this.height) this.top = cursor - this.height + 1;
200
+ body = list.slice(this.top, this.top + this.height).map(run => {
201
+ const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
202
+ const activity = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : tools || (live.streaming ? 'responding' : 'working')} · last activity ${elapsed(Date.now() - live.updated)} ago` : run.state;
203
+ const label = `${run.id === this.selected ? '→' : ' '} ${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task).slice(0, 55)} · ${activity} · ${elapsed((run.ended ?? Date.now()) - run.started)}`;
204
+ return run.id === this.selected ? color('accent', label) : label;
205
+ });
206
+ if (!body.length) body.push('No agents yet. Ask the main agent to delegate a task.');
207
+ body.push(color('dim', `${list.filter(isActiveRun).length} active · ${list.length} saved · ${list.length ? cursor + 1 : 0}/${list.length}`));
208
+ hint = '↑↓ select · Enter inspect · m model · Tab usage · Esc close';
209
+ }
210
+ if (this.composer) { this.composer.focused = this.focused; body.push(...this.composer.render(width)); hint = 'Enter sends guidance · Esc cancels message'; }
211
+ return [header, '', ...body, '', color(this.error ? 'error' : 'dim', this.error || (this.busy ? 'Updating agent…' : hint))].map(line => truncateToWidth(line, width));
212
+ }
213
+ }
214
+
215
+ export function showInspector(ctx: ExtensionContext, options: Omit<Options, 'rows' | 'redraw' | 'done' | 'color' | 'context'>) {
216
+ return ctx.ui.custom<'model' | undefined>((tui, theme, _keys, done) => new Inspector({ ...options,
217
+ rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done,
218
+ color: (tone, text) => theme.fg(tone, text), context: () => ctx.getContextUsage()?.tokens,
219
+ }), { overlay: true, overlayOptions: { width: '96%', maxHeight: '90%', anchor: 'center' } });
220
+ }