pi-optchat 0.7.2 → 0.8.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/src/index.ts CHANGED
@@ -1,48 +1,57 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
- import { createHash } from 'node:crypto';
6
5
  import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
7
6
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
7
  import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
9
8
  import { Type } from 'typebox';
10
- import { Memory } from './memory.ts';
9
+ import { atomicWrite, Memory } from './memory.ts';
11
10
  import { createCompressor } from './compactor.ts';
12
- import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
13
- import { MASTER, VIEW_DOC } from './prompts.ts';
11
+ import { createProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
+ import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, VIEW_DOC } from './prompts.ts';
14
13
  import { cachePayload, record } from './cache.ts';
15
- import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
14
+ import { saveImages } from './images.ts';
15
+ import { asUser, boundedMessage, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
16
16
  import { registerReportRenderer, type ReportDetails } from './report-message.ts';
17
- import { memoryTools, result, SEARCH_DOC, searchTool } from './tools.ts';
17
+ import { memoryTools, result, searchTool } from './tools.ts';
18
18
  import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
19
19
  import { exportBrowser } from './browser.ts';
20
20
  import { Inbox } from './inbox.ts';
21
21
  import { checkpoint } from './checkpoint.ts';
22
22
  import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
23
23
  import { chooseImport, showProgress } from './import/ui.ts';
24
- import { IMPORT_GUIDANCE } from './import/guidance.ts';
25
24
  import { UsageLedger } from './usage.ts';
26
- import { showInspector, type InspectorPage } from './inspector.ts';
25
+ import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
27
26
  import { showAgentView } from './agent-view.ts';
28
27
  import { inspectorShortcut, mountNavigation } from './navigation.ts';
29
- import { serveWindows } from './window-bridge.ts';
28
+ import { joinHeadless, serveWindows } from './window-bridge.ts';
30
29
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
31
30
  import { createHandoffSummarizer } from './handoff.ts';
32
31
  import { mainTitle, TabTitle } from './title.ts';
33
32
  import { showModelPicker, showSettings } from './settings-page.ts';
34
33
 
35
34
  const binding = 'optchat.profile';
36
- const CONTINUITY = '\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; left out when very long), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.';
35
+ const CONNECT_MODES = ['auto', 'join', 'off'] as const;
36
+ /** Print mode routes `process.stdout` to stderr so only the answer reaches stdout; the joined reply goes to fd 1 itself. */
37
+ async function printReply(text: string) {
38
+ const bytes = Buffer.from(`${text}\n`);
39
+ for (let at = 0; at < bytes.length;) {
40
+ try { at += writeSync(1, bytes, at); }
41
+ catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
42
+ }
43
+ }
37
44
  const toggle = (prompt: string, line: string, on: boolean, after: string) => on === prompt.includes(line) ? prompt : on ? prompt.replace(after, after + line) : prompt.replace(line, '');
38
45
  /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
39
46
  const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
40
- toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC);
47
+ allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
41
48
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
42
49
 
43
50
  export default function optchat(pi: ExtensionAPI) {
44
51
  let active: Active | undefined;
45
52
  let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
53
+ /** `pi -p` on a profile another Pi owns: the prompt goes to a subagent in that Pi (--optchat-connect). */
54
+ let joined: Awaited<ReturnType<typeof joinHeadless>> | undefined;
46
55
  let closeWindows: (() => Promise<void>) | undefined;
47
56
  let recovery: Promise<void> | undefined;
48
57
  let run: AgentMessage[] = [];
@@ -52,8 +61,11 @@ export default function optchat(pi: ExtensionAPI) {
52
61
  let prompt = '';
53
62
  let runStarted = false;
54
63
  let fault: string | undefined;
55
- /** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet. */
56
- let reports: { text: string; count?: number; batch?: string }[] = [];
64
+ /** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
65
+ * `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
66
+ let reports: { text: string; count?: number; batch?: string; steered?: boolean }[] = [];
67
+ type Report = (typeof reports)[number];
68
+ let idle = () => true;
57
69
  const receipts = new Map<AgentMessage, string>();
58
70
  let checkpoints = Promise.resolve();
59
71
  let importing = false;
@@ -73,9 +85,20 @@ export default function optchat(pi: ExtensionAPI) {
73
85
  /** Journals written before batches held plain strings. */
74
86
  const isPendingReport = (s: unknown): s is string | { text: string; count?: number } =>
75
87
  typeof s === 'string' || record(s) && typeof s.text === 'string' && (s.count === undefined || typeof s.count === 'number');
76
- const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
77
88
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
78
89
  pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
90
+ pi.registerFlag('optchat-connect', { description: 'pi -p on a profile open in another Pi: auto (join it if busy, default), join (only join), off (error if busy)', type: 'string', default: 'auto' });
91
+ const connectMode = () => {
92
+ const mode = pi.getFlag('optchat-connect') ?? 'auto';
93
+ const known = CONNECT_MODES.find(m => m === mode);
94
+ if (!known) { process.exitCode = 1; throw new Error(`--optchat-connect must be ${CONNECT_MODES.join(', ')}; got ${String(mode)}`); }
95
+ return known;
96
+ };
97
+ /** Pi exits 0 after an extension error; a pipeline must see a failed join. */
98
+ const joinProfile = async (name: string) => {
99
+ try { return await joinHeadless(profilePath(name)); }
100
+ catch (error) { process.exitCode = 1; throw error; }
101
+ };
79
102
  const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
80
103
  const status = (ctx: ExtensionContext) => {
81
104
  const a = active;
@@ -86,14 +109,15 @@ export default function optchat(pi: ExtensionAPI) {
86
109
  catch { note = ' · import journal invalid'; }
87
110
  ctx.ui.setStatus('optchat', `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${note}`);
88
111
  };
89
- const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports)); };
112
+ // `steered` describes this process's Pi queue, so it is not journaled.
113
+ const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports, (key, value: unknown) => key === 'steered' ? undefined : value)); };
90
114
  const flush = () => {
91
115
  if (!active) return;
92
116
  while (logged < run.length) {
93
117
  const message = run[logged];
94
118
  const receipt = receipts.get(message);
95
119
  logMessage(active.memory, message, receipt); logged++;
96
- if (receipt && !receipt.startsWith('report:')) active.inbox.acknowledge(receipt);
120
+ if (receipt && !receipt.startsWith(REPORT_RECEIPT)) active.inbox.acknowledge(receipt);
97
121
  receipts.delete(message);
98
122
  if (message.role === 'user') {
99
123
  const text = textContent(message.content), index = reports.findIndex(r => !r.batch && r.text === text);
@@ -103,21 +127,24 @@ export default function optchat(pi: ExtensionAPI) {
103
127
  };
104
128
  // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
105
129
  // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
106
- const sendReport = (text: string, count?: number) => {
130
+ const sendReport = (report: Report) => {
131
+ const { text, count } = report;
132
+ report.steered = !idle();
107
133
  if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
108
134
  else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
109
135
  };
110
136
  const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
111
137
  if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
112
- reports.push({ text, count }); saveReports();
113
- if (!stopping) sendReport(text, count);
138
+ const report = { text, count };
139
+ reports.push(report); saveReports();
140
+ if (!stopping) sendReport(report);
114
141
  };
115
142
  const holdReports = (batch: string, texts: string[]) => {
116
143
  reports = [...reports.filter(r => r.batch !== batch), ...texts.length ? [{ text: texts.join('\n\n'), count: texts.length, batch }] : []];
117
144
  saveReports();
118
145
  };
119
146
  const stop = async () => {
120
- remote?.close(); remote = undefined;
147
+ remote?.close(); remote = undefined; joined?.close(); joined = undefined;
121
148
  inspectorController?.abort(); unmountNavigation?.(); unmountNavigation = undefined;
122
149
  stopping = true; importController?.abort(); await importTask?.catch(() => {});
123
150
  if (!active) return;
@@ -130,13 +157,14 @@ export default function optchat(pi: ExtensionAPI) {
130
157
  await checkpoint(old.dir);
131
158
  } finally {
132
159
  await old.memory.close(); await old.unlock(); active = undefined;
133
- run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
160
+ run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = ''; idle = () => true;
134
161
  untitle?.(); untitle = undefined; title.clear(); working = false;
135
162
  }
136
163
  };
137
164
  const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
138
165
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
139
- if (!ctx.hasUI) return undefined;
166
+ // Outside the TUI (print, json, RPC hosts) never pick or create a profile: a dialog would hang the host, and a guess could write into the wrong memory.
167
+ if (ctx.mode !== 'tui') return undefined;
140
168
  const names = listProfiles(), last = lastProfile();
141
169
  if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
142
170
  const selected = await ctx.ui.select('OptChat profile', [...names, '+ Create profile']);
@@ -175,15 +203,19 @@ export default function optchat(pi: ExtensionAPI) {
175
203
  .filter(r => !loggedReports.has(reportReceipt(r.text)));
176
204
  atomicWrite(pending, JSON.stringify(reports));
177
205
  rememberProfile(name);
178
- active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
206
+ active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
179
207
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
180
208
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
181
209
  untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
182
210
  status(ctx);
183
211
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
184
212
  const queuedReports = [...reports];
185
- if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
186
- setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r.text, r.count); });
213
+ if (!pendingImport(dir)) {
214
+ const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
215
+ // Only an interactive Pi picks cut-off subagents up: a one-shot headless run would stop them again as it exits.
216
+ recovery = Promise.all([recover(children.recoverHandoffs(), 'Handoff recovery'), ...ctx.mode === 'tui' ? [recover(children.resumeCutOff(), 'Resuming subagents')] : []]).then(() => {});
217
+ }
218
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r); });
187
219
  } catch (error) {
188
220
  await closeWindows?.(); closeWindows = undefined;
189
221
  untitle?.(); untitle = undefined; title.clear();
@@ -207,10 +239,17 @@ export default function optchat(pi: ExtensionAPI) {
207
239
  try {
208
240
  if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
209
241
  let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
242
+ // Only plain `pi -p` joins: JSON and RPC output carry Pi's own events, which a joined reply would bypass.
243
+ const connect = ctx.mode === 'print' ? connectMode() : 'off';
244
+ if (connect === 'join' && !name) { process.exitCode = 1; throw new Error('--optchat-connect join needs a profile: pass --optchat-profile'); }
210
245
  for (;;) {
211
246
  if (!name) { status(ctx); return; }
212
- try { await openProfile(name, ctx); break; }
213
- catch (error) {
247
+ try {
248
+ if (connect === 'join') joined = await joinProfile(name);
249
+ else await openProfile(name, ctx);
250
+ break;
251
+ } catch (error) {
252
+ if (error instanceof ProfileBusyError && connect === 'auto') { joined = await joinProfile(name); break; }
214
253
  if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
215
254
  // A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
216
255
  const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
@@ -222,7 +261,10 @@ export default function optchat(pi: ExtensionAPI) {
222
261
  if (name !== boundName) pi.appendEntry(binding, { name });
223
262
  } catch (error) {
224
263
  if (active) await stop().catch(() => {});
225
- fault = errorText(error); ctx.ui.notify(fault, 'error');
264
+ fault = errorText(error);
265
+ // Pi reports a throwing handler on stderr (print) or as an extension_error event (RPC); notify there is a no-op or easy to miss.
266
+ if (ctx.mode !== 'tui') throw error;
267
+ ctx.ui.notify(fault, 'error');
226
268
  }
227
269
  // Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
228
270
  for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
@@ -232,6 +274,13 @@ export default function optchat(pi: ExtensionAPI) {
232
274
  pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
233
275
  pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
234
276
  pi.on('input', async (event, ctx) => {
277
+ if (joined) {
278
+ try {
279
+ if (event.images?.length) throw new Error('Joining a running profile accepts text only; provide a file path for the agent to read.');
280
+ await printReply(await joined.ask(event.text, ctx.cwd));
281
+ } catch (error) { process.stderr.write(`OptChat: ${errorText(error)}\n`); process.exitCode = 1; }
282
+ return { action: 'handled' };
283
+ }
235
284
  if (remote) {
236
285
  try {
237
286
  if (event.images?.length) throw new Error('Connected windows currently accept text only; provide a file path for the agent to read.');
@@ -239,10 +288,14 @@ export default function optchat(pi: ExtensionAPI) {
239
288
  } catch (error) { ctx.ui.notify(errorText(error), 'error'); ctx.ui.setEditorText(event.text); }
240
289
  return { action: 'handled' };
241
290
  }
242
- if (!active) { ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' }; }
291
+ if (!active) {
292
+ // Without a profile or flag, a headless run is plain Pi. A requested profile that failed to open refuses instead of running without memory.
293
+ if (ctx.mode !== 'tui' && !fault) return { action: 'continue' };
294
+ ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' };
295
+ }
243
296
  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' }; }
244
297
  if (event.source !== 'extension') {
245
- try { active.inbox.record(event.text); }
298
+ try { active.inbox.record(event.text, event.streamingBehavior !== undefined); }
246
299
  catch (error) { ctx.ui.notify(`Could not save input: ${errorText(error)}`, 'error'); return { action: 'handled' }; }
247
300
  }
248
301
  return { action: 'continue' };
@@ -259,24 +312,39 @@ export default function optchat(pi: ExtensionAPI) {
259
312
  if (active && !runStarted) startRun(ctx);
260
313
  });
261
314
  pi.on('before_agent_start', (event, ctx) => {
315
+ if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
262
316
  startRun(ctx);
263
317
  const a = required();
264
318
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
265
319
  syncSearch(a.config);
266
- event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`;
320
+ event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
267
321
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
268
322
  prompt = event.systemPrompt;
269
323
  });
270
- pi.on('message_end', (event, ctx) => {
324
+ const DUPLICATE_REPORT = '(duplicate report, already delivered)';
325
+ pi.on('message_end', async (event, ctx) => {
271
326
  if (!active || !runStarted) return;
272
327
  const bounded = boundedMessage(event.message);
273
328
  const message = asUser(bounded);
329
+ // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
330
+ if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
331
+ try { await saveImages(active.memory.directory, message.content); }
332
+ catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
333
+ }
334
+ if (!active) return;
274
335
  if (message.role === 'user') {
275
336
  try {
276
337
  const text = textContent(message.content);
277
- if (reports.some(r => !r.batch && r.text === text)) receipts.set(message, reportReceipt(text));
278
- else {
279
- // The inbox journaled the typed input: match without image placeholders or Pi's image notes.
338
+ // A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
339
+ if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) {
340
+ // A report sent again after a lost steer can still arrive twice (Pi kept the old steer after an abort): drop the extra copy.
341
+ // Copies are counted, so a later report that happens to repeat an earlier text still arrives.
342
+ const receipt = reportReceipt(text), copies = run.slice(logged).filter(m => receipts.get(m) === receipt).length;
343
+ if (reports.filter(r => !r.batch && r.text === text).length <= copies && (copies > 0 || active.memory.root.some(e => e.receipt === receipt)))
344
+ return { message: bounded.role === 'custom' ? { ...bounded, content: DUPLICATE_REPORT, display: false } : { ...message, content: DUPLICATE_REPORT } };
345
+ receipts.set(message, receipt);
346
+ } else {
347
+ // The inbox journaled the typed input: match without image references or Pi's image notes.
280
348
  const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
281
349
  let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
282
350
  ?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
@@ -293,12 +361,21 @@ export default function optchat(pi: ExtensionAPI) {
293
361
  });
294
362
  pi.on('context_with_system', async (event, ctx) => {
295
363
  try {
364
+ if (!active) return; // Plain Pi run: pass context through unmodified.
296
365
  const a = required();
297
366
  if (importing || pendingImport(a.dir)) throw new Error('Profile is unavailable while importing.');
298
367
  if (fault) throw new Error(fault);
299
368
  if (view === undefined) {
300
- ctx.ui.setWorkingMessage('Waiting for OptChat summaries…');
301
- try { await a.memory.settle(ctx.signal); } finally { ctx.ui.setWorkingMessage(); }
369
+ // A misconfigured summarizer retries forever, so say why instead of spinning silently.
370
+ let shown: string | undefined;
371
+ const show = () => {
372
+ const error = a.memory.lastError;
373
+ const text = `Waiting for OptChat summaries…${error ? ` failing: ${oneLine(error)}${error.includes('/optchat') ? '' : ' (see /optchat model)'}` : ''}`;
374
+ if (text !== shown) ctx.ui.setWorkingMessage(shown = text);
375
+ };
376
+ show();
377
+ const unsubscribe = a.memory.onChange(show);
378
+ try { await a.memory.settle(ctx.signal); } finally { unsubscribe(); ctx.ui.setWorkingMessage(); }
302
379
  view = a.memory.render(); // Capture old history before logging the new input.
303
380
  flush();
304
381
  }
@@ -324,7 +401,9 @@ export default function optchat(pi: ExtensionAPI) {
324
401
  pi.on('turn_end', (_event, ctx) => collectUsage(ctx));
325
402
  pi.on('agent_settled', async (_event, ctx) => {
326
403
  collectUsage(ctx);
327
- try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
404
+ try { flush(); active?.inbox.dropReturned(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
405
+ // A report steered into this run that never reached it (Esc or a dequeue cleared Pi's queue) is sent again, now as its own turn.
406
+ if (active && !fault && !stopping) for (const r of reports) if (r.steered && !r.batch) sendReport(r);
328
407
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
329
408
  runStarted = false; status(ctx);
330
409
  working = false; showTitle(ctx);
@@ -424,7 +503,7 @@ export default function optchat(pi: ExtensionAPI) {
424
503
  const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
425
504
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
426
505
  }, () => a.config.summaryAcceptBytes);
427
- const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, progress), signal);
506
+ const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal);
428
507
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
429
508
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
430
509
  })();
@@ -438,6 +517,7 @@ export default function optchat(pi: ExtensionAPI) {
438
517
  }
439
518
  if (action === 'profile') {
440
519
  if (!ctx.isIdle() || active?.children.active) throw new Error('Finish or stop active work before switching profiles.');
520
+ if (ctx.mode !== 'tui') throw new Error('/optchat profile requires interactive Pi. Start headless runs with --optchat-profile <name>.');
441
521
  const selected = await chooseProfile(ctx);
442
522
  if (!selected || selected === active?.name) return;
443
523
  await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: selected }); } });
@@ -459,7 +539,8 @@ export default function optchat(pi: ExtensionAPI) {
459
539
  }
460
540
  if (action === 'browse') {
461
541
  const a = required(), file = exportBrowser(a.memory, a.name, a.dir);
462
- if (ctx.hasUI) execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error) ctx.ui.notify(`Open ${file}`, 'info'); });
542
+ // explorer.exe exits 1 even when it opened the file, so only its launch failing counts there.
543
+ if (ctx.hasUI) execFile(isWindows ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error && (!isWindows || error.code === 'ENOENT')) ctx.ui.notify(`Open ${file}`, 'info'); });
463
544
  ctx.ui.notify(file, 'info'); return;
464
545
  }
465
546
  if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|activity|instructions|browse|import].');
package/src/inspector.ts CHANGED
@@ -60,6 +60,8 @@ export class Inspector implements Component, Focusable {
60
60
  private lineCount = 0;
61
61
  private hintLines = 1;
62
62
  private range: UsageRange = 'This session';
63
+ private confirmDelete?: string;
64
+ private notice = '';
63
65
  private ended = false;
64
66
  private readonly unsubscribe: (() => void)[];
65
67
  private readonly timer: ReturnType<typeof setInterval>;
@@ -87,6 +89,7 @@ export class Inspector implements Component, Focusable {
87
89
  handleInput(data: string) {
88
90
  if (this.ended) return;
89
91
  if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish();
92
+ const deleting = this.confirmDelete; this.confirmDelete = undefined; this.notice = '';
90
93
  if (matchesKey(data, 'tab')) {
91
94
  this.page = nextPage(this.page); this.scroll = 0;
92
95
  } else if (this.page === 'activity') this.scrollInput(data);
@@ -103,10 +106,22 @@ export class Inspector implements Component, Focusable {
103
106
  else if (matchesKey(data, 'home')) this.select(-Infinity);
104
107
  else if (matchesKey(data, 'end')) this.select(Infinity);
105
108
  else if (data === 'm') return this.finish('model');
109
+ else if ((data === 'd' || matchesKey(data, 'delete')) && this.selected) {
110
+ if (deleting === this.selected) this.remove(this.selected);
111
+ else this.confirmDelete = this.selected;
112
+ }
106
113
  else if (matchesKey(data, 'return') && this.selected) return this.finish({ open: this.selected });
107
114
  }
108
115
  this.options.redraw();
109
116
  }
117
+ /** Once the run is gone, the cursor moves to the next row that is not, else the one above. */
118
+ private remove(id: string) {
119
+ const { children } = this.options, list = children.history.list(), doomed = new Set([id, ...children.history.descendants(id).map(r => r.id)]);
120
+ const index = list.findIndex(r => r.id === id), next = list.slice(index).find(r => !doomed.has(r.id)) ?? list.slice(0, index).findLast(r => !doomed.has(r.id));
121
+ children.remove(id).then(() => { if (!children.history.records.has(this.selected ?? '')) this.selected = next?.id; },
122
+ (error: unknown) => { this.notice = `Not deleted: ${error instanceof Error ? error.message : String(error)}`; })
123
+ .finally(() => { if (!this.ended) this.options.redraw(); });
124
+ }
110
125
  private scrollInput(data: string) {
111
126
  const delta = matchesKey(data, 'up') ? -1 : matchesKey(data, 'down') ? 1 : matchesKey(data, 'pageUp') ? -this.height : matchesKey(data, 'pageDown') ? this.height : 0;
112
127
  if (delta || matchesKey(data, 'home') || matchesKey(data, 'end')) {
@@ -191,10 +206,12 @@ export class Inspector implements Component, Focusable {
191
206
  });
192
207
  if (!body.length) body.push(color('muted', 'No agents yet. Ask the main agent to delegate a task.'));
193
208
  info = `${list.filter(isActiveRun).length} active · ${list.length} saved${list.length ? ` · ${cursor + 1}/${list.length}` : ''}`;
194
- hint = '↑↓ select · Enter open · m model · Tab usage · Esc close';
209
+ hint = this.confirmDelete ? `Press d again to ${children.live(this.confirmDelete) ? 'stop and delete' : 'delete'} this agent and the agents it started`
210
+ : this.notice || '↑↓ select · Enter open · m model · d delete · Tab usage · Esc close';
195
211
  }
196
212
  title = fit(title, Math.max(1, inner - visibleWidth(info) - 2));
197
- const footer = wrapTextWithAnsi(hint, inner).map(line => color('dim', line));
213
+ const tone = this.page !== 'agents' ? 'dim' : this.confirmDelete ? 'warning' : this.notice ? 'error' : 'dim';
214
+ const footer = wrapTextWithAnsi(hint, inner).map(line => color(tone, line));
198
215
  this.hintLines = footer.length;
199
216
  const rule = color('border', '─'.repeat(Math.max(1, width)));
200
217
  // Same layout as Pi's own selectors: rules above and below, content indented by one column.