pi-optchat 0.7.2 → 0.8.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.
@@ -6,6 +6,7 @@ import { homedir } from 'node:os';
6
6
  import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { createHash } from 'node:crypto';
9
+ import { getAgentDir, parseSkillBlock } from '@earendil-works/pi-coding-agent';
9
10
  import { record } from '../cache.ts';
10
11
  import { bytes, type Entry, type Kind, type Origin } from '../memory.ts';
11
12
 
@@ -15,7 +16,7 @@ export interface Conversation {
15
16
  source: Source; id: string; file: string; title: string; project: string; date: string; size: number;
16
17
  exported?: Record<string, unknown>;
17
18
  }
18
- export interface Scan { conversations: Conversation[]; warnings: string[] }
19
+ export interface Scan { conversations: Conversation[]; warnings: string[]; note?: string }
19
20
  const exec = promisify(execFile);
20
21
  const string = (v: unknown) => typeof v === 'string' ? v : undefined;
21
22
  const codexSubagent = (metadata: Record<string, unknown>) => metadata.source === 'subagent'
@@ -65,6 +66,39 @@ const CODEX_EXACT = /^(?:<external_([^>]+)>[\s\S]*<\/external_\1>|<codex_interna
65
66
  /** What the user typed in a Codex message: every part except the context Codex injected. */
66
67
  const codexTyped = (content: unknown) => (Array.isArray(content) ? content : [content]).map(text)
67
68
  .filter(piece => piece && !CODEX_MARKED.test(piece.trim()) && !CODEX_EXACT.test(piece.trim())).join('\n');
69
+ /**
70
+ * Pi and OMP write the same session format: an id/parentId tree in file order. OMP injects reminders and nudges as user
71
+ * messages it attributes to the agent; the user typed everything else.
72
+ */
73
+ const piTyped = (m: Record<string, unknown>) => m.role === 'user' && m.attribution !== 'agent' && m.synthetic !== true;
74
+ /** OptChat writes `optchat.*` entries into every session it runs; those messages are already in a profile's memory. */
75
+ const optchatEntry = (v: Record<string, unknown>) => v.type === 'custom' && String(v.customType).startsWith('optchat.');
76
+ /** Pi's sessions, then OMP's: its default profile in ~/.omp/agent/sessions, others in ~/.omp/profiles/<name>/agent/sessions. */
77
+ async function piRoots(): Promise<string[]> {
78
+ const profiles = join(homedir(), '.omp/profiles');
79
+ const entries = await readdir(profiles, { withFileTypes: true }).catch(error => {
80
+ if (missingSource(error)) return [];
81
+ throw error;
82
+ });
83
+ const named = entries.filter(e => e.isDirectory()).map(e => join(profiles, e.name, 'agent/sessions')).sort();
84
+ return [join(getAgentDir(), 'sessions'), join(homedir(), '.omp/agent/sessions'), ...named];
85
+ }
86
+ /** What the user typed for a Pi or OMP session message, or undefined for anything they did not type. */
87
+ function piUserText(v: Record<string, unknown>): string | undefined {
88
+ // OMP records `/skill:name args` as its own message, holding the skill's body. Newer versions keep the whole typed prompt;
89
+ // older ones only the name and what followed it.
90
+ if (v.type === 'custom_message' && v.customType === 'skill-prompt' && v.attribution === 'user' && record(v.details) && typeof v.details.name === 'string')
91
+ return string(v.details.prompt)?.trim() || `/skill:${v.details.name} ${string(v.details.args) ?? ''}`.trimEnd();
92
+ const m = v.type === 'message' ? v.message : undefined;
93
+ if (!record(m)) return undefined;
94
+ // Pi expands `/skill:name args` into the skill's body followed by the args.
95
+ if (piTyped(m)) { const typed = text(m.content), skill = parseSkillBlock(typed); return skill ? `/skill:${skill.name} ${skill.userMessage ?? ''}`.trimEnd() : typed; }
96
+ // `!cmd` and `$code` the user ran: the command as typed, never its output. `!!` and `$$` kept them from the model, so they stay out.
97
+ if (m.excludeFromContext === true) return undefined;
98
+ if (m.role === 'bashExecution' && typeof m.command === 'string') return `!${m.command}`;
99
+ if (m.role === 'pythonExecution' && typeof m.code === 'string') return `$${m.code}`;
100
+ return undefined;
101
+ }
68
102
  const digest = (s: string) => createHash('sha256').update(s).digest('hex');
69
103
  /**
70
104
  * A resumed Claude Code session copies the earlier messages into its own file, each with its uuid and text, so a copy
@@ -83,7 +117,8 @@ function imported(c: Conversation, id: string, kind: Kind, content: string, date
83
117
  // The agent reads only text, so the id's first 13 characters stay in it: enough to find a Claude or Codex transcript by
84
118
  // glob. Codex ids are UUIDv7, whose first 8 characters are a coarse timestamp shared by many sessions.
85
119
  return { kind, date, origin, text: `[Historical ${c.source} · ${minute(date)} · ${c.id.slice(0, 13)} · ${c.title}]\n${content}`,
86
- receipt: `import:${digest(JSON.stringify([c.source, c.id, id, kind, identity]))}` };
120
+ // A Pi or OMP fork copies the earlier entries, ids and dates included, into a new session, so the session id stays out.
121
+ receipt: `import:${digest(JSON.stringify(c.source === 'pi' ? [c.source, id, kind, date, identity] : [c.source, c.id, id, kind, identity]))}` };
87
122
  }
88
123
  async function* jsonLines(file: string, warnings: string[], limit = Infinity, signal?: AbortSignal) {
89
124
  const stream = createReadStream(file, { encoding: 'utf8', signal });
@@ -111,22 +146,31 @@ async function filesUnder(path: string, accept: (name: string) => boolean, signa
111
146
  }
112
147
  return files.sort();
113
148
  }
114
- export async function scanLocal(source: 'claude' | 'codex', roots?: string[], signal?: AbortSignal): Promise<Scan> {
149
+ export async function scanLocal(source: 'claude' | 'codex' | 'pi', roots?: string[], signal?: AbortSignal): Promise<Scan> {
115
150
  const folders = roots ?? (source === 'claude' ? [join(homedir(), '.claude/projects')]
151
+ : source === 'pi' ? await piRoots()
116
152
  : [join(homedir(), '.codex/sessions'), join(homedir(), '.codex/archived_sessions')]);
117
153
  const conversations: Conversation[] = [], warnings: string[] = [];
154
+ let underOptChat = 0;
118
155
  // Claude workflow journals contain orchestration events, not conversation messages.
119
156
  for (const folder of folders) for (const file of await filesUnder(folder, n => n.endsWith('.jsonl') && !(source === 'claude' && n === 'journal.jsonl'), signal)) {
120
157
  signal?.throwIfAborted();
121
158
  // Import user conversations, not separate delegated runs (including Claude's older flat layout).
122
159
  if (source === 'claude' && (relative(folder, dirname(file)).split(/[\\/]/).includes('subagents') || basename(file).startsWith('agent-'))) continue;
160
+ // OMP keeps a session's subagent and advisor logs in a folder named after the session, below the project folder.
161
+ if (source === 'pi' && relative(folder, file).split(/[\\/]/).length > 2) continue;
123
162
  try {
124
163
  const info = await stat(file);
125
164
  let id = basename(file, '.jsonl'), project = dirname(file), date = info.mtime.toISOString(), title = '';
165
+ // OMP's own title: a first line it rewrites on rename, else the header's.
166
+ let named = '', renamed = '';
126
167
  let sidechain = false;
127
- for await (const { value: v, line } of jsonLines(file, warnings, source === 'claude' ? Infinity : 60, signal)) {
128
- // Sidechain markers can appear late; picker metadata still comes from the first 60 lines.
168
+ for await (const { value: v, line } of jsonLines(file, warnings, source === 'codex' ? 60 : Infinity, signal)) {
169
+ // Sidechain and OptChat markers can appear late; picker metadata still comes from the first 60 lines.
129
170
  if (source === 'claude' && v.isSidechain === true) { sidechain = true; break; }
171
+ if (source === 'pi' && optchatEntry(v)) { sidechain = true; underOptChat++; break; }
172
+ // Pi's `/name` can come at any point; the latest wins.
173
+ if (source === 'pi' && v.type === 'session_info') renamed = string(v.name)?.trim() || renamed;
130
174
  if (line > 60) continue;
131
175
  if (source === 'codex' && v.type === 'session_meta' && record(v.payload)) {
132
176
  if (codexSubagent(v.payload)) { sidechain = true; break; }
@@ -137,23 +181,49 @@ export async function scanLocal(source: 'claude' | 'codex', roots?: string[], si
137
181
  project = string(v.cwd) ?? project;
138
182
  if (v.type === 'custom-title' || v.type === 'ai-title') title = string(v.customTitle ?? v.aiTitle) ?? title;
139
183
  }
184
+ if (source === 'pi') {
185
+ if (v.type === 'title' && line === 1) named = string(v.title) ?? named;
186
+ else if (v.type === 'session') {
187
+ id = string(v.id) ?? id; project = string(v.cwd) ?? project; date = timestamp(v.timestamp, date);
188
+ if (!named) named = string(v.title) ?? '';
189
+ }
190
+ }
140
191
  const m = source === 'claude' ? v.message : v.type === 'response_item' ? v.payload : undefined;
141
- const user = record(m) && m.role === 'user' && !title;
142
- const first = !user ? '' : source === 'claude' ? claudeCommand(text(m.content)) ?? text(m.content) : codexTyped(m.content);
192
+ const user = source === 'pi' || record(m) && m.role === 'user';
193
+ const first = !user || title ? '' : source === 'pi' ? piUserText(v) ?? '' : !record(m) ? ''
194
+ : source === 'claude' ? claudeCommand(text(m.content)) ?? text(m.content) : codexTyped(m.content);
143
195
  if (first.trim()) {
144
196
  title = first.replace(/\s+/g, ' ').slice(0, 110);
145
197
  date = timestamp(v.timestamp, date);
146
198
  }
147
199
  }
148
200
  if (sidechain) continue;
149
- conversations.push({ source, file, id, project, date, title: title || id, size: info.size });
201
+ conversations.push({ source, file, id, project, date, title: renamed || named || title || id, size: info.size });
150
202
  } catch (error) {
151
203
  signal?.throwIfAborted();
152
204
  if (!missingSource(error)) throw error;
153
205
  warnings.push(missingWarning(file));
154
206
  }
155
207
  }
156
- return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings };
208
+ return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings,
209
+ note: underOptChat ? `${underOptChat} session${underOptChat === 1 ? '' : 's'} ran under OptChat and ${underOptChat === 1 ? 'is' : 'are'} already in a profile's memory; skipped.` : undefined };
210
+ }
211
+ /**
212
+ * Lists a ZIP, or reads one member, with the system's own tool, which checks CRCs and handles ZIP64: `unzip` on macOS and
213
+ * Linux, and on Windows the bsdtar in System32 since Windows 10 (by full path, because Git's GNU tar on PATH can't read ZIP).
214
+ */
215
+ async function unzip(zip: string, member?: string, signal?: AbortSignal) {
216
+ const windows = process.platform === 'win32';
217
+ const tool = windows ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'unzip';
218
+ const args = member === undefined ? (windows ? ['-tf', zip] : ['-Z1', zip]) : windows ? ['-xOf', zip, member] : ['-p', zip, member];
219
+ try { return (await exec(tool, args, { signal, maxBuffer: member === undefined ? 10_000_000 : 1_000_000_000, windowsHide: true })).stdout; }
220
+ catch (error) {
221
+ if (signal?.aborted || !record(error)) throw error;
222
+ // unzip opens with a `[file]` line and wraps its message, so keep the first sentence after it.
223
+ const said = string(error.stderr)?.split(/\r?\n/).filter(line => !line.trim().startsWith('[')).join(' ').replace(/\s+/g, ' ').trim();
224
+ const reason = error.code === 'ENOENT' ? `${tool} was not found` : said?.match(/^.*?[.!](?=\s|$)/)?.[0] ?? (said?.slice(0, 200) || String(error.message));
225
+ throw new Error(`Could not read ${member === undefined ? zip : `${member} in ${zip}`}: ${reason.replace(/[.!]$/, '')}. Extract the ZIP and select the folder instead.`);
226
+ }
157
227
  }
158
228
  export async function scanChatGPT(input: string, signal?: AbortSignal): Promise<Scan> {
159
229
  const path = resolve(input.startsWith('~/') ? join(homedir(), input.slice(2)) : input);
@@ -163,11 +233,8 @@ export async function scanChatGPT(input: string, signal?: AbortSignal): Promise<
163
233
  if (info.isDirectory()) {
164
234
  for (const file of await filesUnder(path, accept, signal)) documents.push({ file, content: await readFile(file, { encoding: 'utf8', signal }) });
165
235
  } else if (path.toLowerCase().endsWith('.zip')) {
166
- const listing = await exec('unzip', ['-Z1', path], { signal, maxBuffer: 10_000_000 });
167
- for (const name of listing.stdout.split('\n').filter(accept)) {
168
- const result = await exec('unzip', ['-p', path, name], { signal, maxBuffer: 1_000_000_000 });
169
- documents.push({ file: `${path}:${name}`, content: result.stdout });
170
- }
236
+ for (const name of (await unzip(path, undefined, signal)).split(/\r?\n/).filter(accept))
237
+ documents.push({ file: `${path}:${name}`, content: await unzip(path, name, signal) });
171
238
  } else documents.push({ file: path, content: await readFile(path, { encoding: 'utf8', signal }) });
172
239
  if (!documents.length) throw new Error('No conversations.json or numbered conversation JSON files found. Select a ChatGPT export ZIP, extracted folder, or JSON file.');
173
240
  const conversations: Conversation[] = [], warnings: string[] = [];
@@ -271,6 +338,21 @@ async function readMemory(c: Conversation, signal?: AbortSignal): Promise<{ entr
271
338
  receipt: `import:${digest(JSON.stringify([c.source, c.id, hash]))}` }] };
272
339
  }
273
340
 
341
+ /** Entries of a Pi or OMP session that are not on the path from its last entry back to the root: branches left by a rewind. */
342
+ async function rewound(file: string, signal?: AbortSignal): Promise<Set<string>> {
343
+ const parents = new Map<string, string | undefined>();
344
+ let leaf: string | undefined;
345
+ for await (const { value: v } of jsonLines(file, [], Infinity, signal)) {
346
+ const id = string(v.id);
347
+ if (v.type === 'session' || !id) continue;
348
+ parents.set(id, string(v.parentId)); leaf = id;
349
+ }
350
+ // Older logs without parent links are one straight line.
351
+ if (![...parents.values()].some(Boolean)) return new Set();
352
+ const path = new Set<string>();
353
+ for (let at = leaf; at && !path.has(at); at = parents.get(at)) path.add(at);
354
+ return new Set([...parents.keys()].filter(id => !path.has(id)));
355
+ }
274
356
  export async function readConversation(c: Conversation, signal?: AbortSignal): Promise<{ entries: ImportedEntry[]; warnings: string[] }> {
275
357
  signal?.throwIfAborted();
276
358
  if (c.source === 'claude-memory') return readMemory(c, signal);
@@ -348,6 +430,7 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
348
430
  if (final) finish();
349
431
  };
350
432
  try {
433
+ const offPath = c.source === 'pi' ? await rewound(c.file, signal) : new Set<string>();
351
434
  for await (const { value: v, line } of jsonLines(c.file, warnings, Infinity, signal)) {
352
435
  const date = timestamp(v.timestamp, c.date);
353
436
  // Context replay and compaction scaffolding are not new user requests.
@@ -379,6 +462,17 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
379
462
  else if (!final) pending = [];
380
463
  }
381
464
  }
465
+ if (c.source === 'pi') {
466
+ const id = string(v.id) ?? `line:${line}`, typed = piUserText(v), m = v.type === 'message' ? v.message : undefined;
467
+ // Off the path to the session's last entry: rewound, so kept for its intent but marked as not the outcome.
468
+ const mark = (content: string) => offPath.has(id) ? `[alternate branch]\n${content}` : content;
469
+ if (typed !== undefined) add(id, 'user', mark(typed), date, typed);
470
+ // A turn's answer is a reply that ended on its own and calls no tool. Any other reply is work in progress.
471
+ else if (record(m) && m.role === 'assistant' && (m.stopReason === 'stop' || m.stopReason === 'length') && Array.isArray(m.content)
472
+ && !m.content.some(block => record(block) && block.type === 'toolCall')) m.content.forEach((block: unknown, index: number) => {
473
+ if (record(block) && block.type === 'text') add(`${id}:${index}`, 'talk', mark(text(block)), date, text(block));
474
+ });
475
+ }
382
476
  if (c.source === 'codex' && v.type === 'session_meta' && record(v.payload) && codexSubagent(v.payload)) return { entries: [], warnings };
383
477
  if (c.source === 'codex' && v.type === 'event_msg' && record(v.payload)) {
384
478
  if (v.payload.type === 'task_complete') finish();
package/src/import/ui.ts CHANGED
@@ -5,14 +5,16 @@ import { scanLocal, scanChatGPT, scanClaudeMemories, readConversation, type Conv
5
5
  import { deduplicate, type ImportMode, type ImportJob, type ImportProgress } from './job.ts';
6
6
  import { selectMany } from './multi-select.ts';
7
7
  import { homedir } from 'node:os';
8
+ import { sep } from 'node:path';
8
9
 
9
10
  type ImportUI = { ui: Pick<ExtensionUIContext, 'select' | 'input' | 'confirm' | 'notify' | 'setWidget' | 'custom'> };
10
11
  const clean = (s: string) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
11
12
  const size = (n: number) => `${(n / 1_000_000).toFixed(1)} MB`;
12
13
  export async function chooseImport(ctx: ImportUI, profile: string, memory: Memory, model: string, signal: AbortSignal): Promise<{ entries: ImportedEntry[]; mode: ImportMode } | undefined> {
13
- const sourceLabel = await ctx.ui.select(`Import into ${profile} · source`, ['Claude Code', 'Claude Code memories', 'Codex', 'ChatGPT export'], { signal });
14
+ const sourceLabel = await ctx.ui.select(`Import into ${profile} · source`, ['Claude Code', 'Claude Code memories', 'Codex', 'Pi / OMP', 'ChatGPT export'], { signal });
14
15
  if (!sourceLabel) return;
15
- const source: Source = sourceLabel === 'Claude Code' ? 'claude' : sourceLabel === 'Claude Code memories' ? 'claude-memory' : sourceLabel === 'Codex' ? 'codex' : 'chatgpt';
16
+ const source: Source = sourceLabel === 'Claude Code' ? 'claude' : sourceLabel === 'Claude Code memories' ? 'claude-memory'
17
+ : sourceLabel === 'Codex' ? 'codex' : sourceLabel === 'Pi / OMP' ? 'pi' : 'chatgpt';
16
18
  const unit = source === 'claude-memory' ? 'memories' : 'conversations';
17
19
  ctx.ui.setWidget('optchat-import', ['Scanning local conversation metadata…']);
18
20
  let scan;
@@ -24,12 +26,12 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
24
26
  } else scan = source === 'claude-memory' ? await scanClaudeMemories(undefined, signal) : await scanLocal(source, undefined, signal);
25
27
  } finally { ctx.ui.setWidget('optchat-import', undefined); }
26
28
  let candidates = scan.conversations;
27
- if (!candidates.length) throw new Error(`No ${unit} found for this source.${scan.warnings.length ? '\n' + scan.warnings.slice(0, 4).map(clean).join('\n') : ''}`);
29
+ if (!candidates.length) throw new Error(`No ${unit} found for this source.${[...scan.note ? [scan.note] : [], ...scan.warnings.slice(0, 4)].map(w => '\n' + clean(w)).join('')}`);
28
30
  if (source !== 'chatgpt') {
29
31
  const counts = new Map<string, number>();
30
32
  for (const c of candidates) counts.set(c.project, (counts.get(c.project) ?? 0) + 1);
31
33
  const projects = [...counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([p]) => p);
32
- const selected = await selectMany(ctx.ui, 'Projects', projects, p => `${p.startsWith(homedir() + '/') ? '~' + p.slice(homedir().length) : p} (${counts.get(p)} ${unit})`, signal);
34
+ const selected = await selectMany(ctx.ui, scan.note ? `Projects · ${scan.note}` : 'Projects', projects, p => `${p.startsWith(homedir() + sep) ? '~' + p.slice(homedir().length) : p} (${counts.get(p)} ${unit})`, signal);
33
35
  if (!selected) return;
34
36
  candidates = candidates.filter(c => selected.includes(c.project));
35
37
  }
package/src/inbox.ts CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { record } from './cache.ts';
5
- import { atomicWrite } from './profiles.ts';
5
+ import { atomicWrite } from './memory.ts';
6
6
  import type { Memory } from './memory.ts';
7
7
 
8
8
  interface Arrival { id: string; text: string; date: string }
@@ -11,6 +11,8 @@ export class Inbox {
11
11
  private readonly file: string;
12
12
  private items: Arrival[];
13
13
  private readonly claimed = new Set<string>();
14
+ /** Typed while a run was going: Pi holds these until a turn boundary, and Esc or dequeue hands them back to the editor without telling extensions. */
15
+ private readonly queued = new Set<string>();
14
16
  constructor(directory: string) {
15
17
  this.file = join(directory, 'pending-inputs.json');
16
18
  const saved: unknown = existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : [];
@@ -19,9 +21,18 @@ export class Inbox {
19
21
  this.items = saved;
20
22
  }
21
23
  private save() { atomicWrite(this.file, JSON.stringify(this.items)); }
22
- record(text: string) {
24
+ record(text: string, queued = false) {
23
25
  const item = { id: randomUUID(), text, date: new Date().toISOString() };
24
- this.items.push(item); this.save(); return item.id;
26
+ this.items.push(item); this.save();
27
+ if (queued) this.queued.add(item.id);
28
+ return item.id;
29
+ }
30
+ /** Call once a run settles: Pi has delivered everything it still held, so a queued input nobody claimed went back to the editor. */
31
+ dropReturned() {
32
+ const returned = new Set([...this.queued].filter(id => !this.claimed.has(id)));
33
+ this.queued.clear();
34
+ if (!returned.size) return;
35
+ this.items = this.items.filter(i => !returned.has(i.id)); this.save();
25
36
  }
26
37
  claim(text: string) { return this.claimWhere(i => i.text === text); }
27
38
  /** Pi expands `/skill:name args` after the input is journaled; match the expansion back to that input. */
@@ -43,6 +54,6 @@ export class Inbox {
43
54
  for (const item of this.items) {
44
55
  if (!receipts.has(item.id)) { memory.append('user', item.text, item.date, item.id); count++; }
45
56
  }
46
- this.items = []; this.claimed.clear(); this.save(); return count;
57
+ this.items = []; this.claimed.clear(); this.queued.clear(); this.save(); return count;
47
58
  }
48
59
  }
package/src/index.ts CHANGED
@@ -1,20 +1,20 @@
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';
11
+ import { createProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
13
12
  import { MASTER, 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 { allowSearch, memoryTools, result, SEARCH_DOC, 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';
@@ -23,26 +23,37 @@ import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport
23
23
  import { chooseImport, showProgress } from './import/ui.ts';
24
24
  import { IMPORT_GUIDANCE } from './import/guidance.ts';
25
25
  import { UsageLedger } from './usage.ts';
26
- import { showInspector, type InspectorPage } from './inspector.ts';
26
+ import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
27
27
  import { showAgentView } from './agent-view.ts';
28
28
  import { inspectorShortcut, mountNavigation } from './navigation.ts';
29
- import { serveWindows } from './window-bridge.ts';
29
+ import { joinHeadless, serveWindows } from './window-bridge.ts';
30
30
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
31
31
  import { createHandoffSummarizer } from './handoff.ts';
32
32
  import { mainTitle, TabTitle } from './title.ts';
33
33
  import { showModelPicker, showSettings } from './settings-page.ts';
34
34
 
35
35
  const binding = 'optchat.profile';
36
+ const CONNECT_MODES = ['auto', 'join', 'off'] as const;
37
+ /** Print mode routes `process.stdout` to stderr so only the answer reaches stdout; the joined reply goes to fd 1 itself. */
38
+ async function printReply(text: string) {
39
+ const bytes = Buffer.from(`${text}\n`);
40
+ for (let at = 0; at < bytes.length;) {
41
+ try { at += writeSync(1, bytes, at); }
42
+ catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
43
+ }
44
+ }
36
45
  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.';
37
46
  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
47
  /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
39
48
  const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
40
- toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC);
49
+ allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
41
50
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
42
51
 
43
52
  export default function optchat(pi: ExtensionAPI) {
44
53
  let active: Active | undefined;
45
54
  let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
55
+ /** `pi -p` on a profile another Pi owns: the prompt goes to a subagent in that Pi (--optchat-connect). */
56
+ let joined: Awaited<ReturnType<typeof joinHeadless>> | undefined;
46
57
  let closeWindows: (() => Promise<void>) | undefined;
47
58
  let recovery: Promise<void> | undefined;
48
59
  let run: AgentMessage[] = [];
@@ -73,9 +84,20 @@ export default function optchat(pi: ExtensionAPI) {
73
84
  /** Journals written before batches held plain strings. */
74
85
  const isPendingReport = (s: unknown): s is string | { text: string; count?: number } =>
75
86
  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
87
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
78
88
  pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
89
+ 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' });
90
+ const connectMode = () => {
91
+ const mode = pi.getFlag('optchat-connect') ?? 'auto';
92
+ const known = CONNECT_MODES.find(m => m === mode);
93
+ if (!known) { process.exitCode = 1; throw new Error(`--optchat-connect must be ${CONNECT_MODES.join(', ')}; got ${String(mode)}`); }
94
+ return known;
95
+ };
96
+ /** Pi exits 0 after an extension error; a pipeline must see a failed join. */
97
+ const joinProfile = async (name: string) => {
98
+ try { return await joinHeadless(profilePath(name)); }
99
+ catch (error) { process.exitCode = 1; throw error; }
100
+ };
79
101
  const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
80
102
  const status = (ctx: ExtensionContext) => {
81
103
  const a = active;
@@ -93,7 +115,7 @@ export default function optchat(pi: ExtensionAPI) {
93
115
  const message = run[logged];
94
116
  const receipt = receipts.get(message);
95
117
  logMessage(active.memory, message, receipt); logged++;
96
- if (receipt && !receipt.startsWith('report:')) active.inbox.acknowledge(receipt);
118
+ if (receipt && !receipt.startsWith(REPORT_RECEIPT)) active.inbox.acknowledge(receipt);
97
119
  receipts.delete(message);
98
120
  if (message.role === 'user') {
99
121
  const text = textContent(message.content), index = reports.findIndex(r => !r.batch && r.text === text);
@@ -117,7 +139,7 @@ export default function optchat(pi: ExtensionAPI) {
117
139
  saveReports();
118
140
  };
119
141
  const stop = async () => {
120
- remote?.close(); remote = undefined;
142
+ remote?.close(); remote = undefined; joined?.close(); joined = undefined;
121
143
  inspectorController?.abort(); unmountNavigation?.(); unmountNavigation = undefined;
122
144
  stopping = true; importController?.abort(); await importTask?.catch(() => {});
123
145
  if (!active) return;
@@ -136,7 +158,8 @@ export default function optchat(pi: ExtensionAPI) {
136
158
  };
137
159
  const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
138
160
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
139
- if (!ctx.hasUI) return undefined;
161
+ // 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.
162
+ if (ctx.mode !== 'tui') return undefined;
140
163
  const names = listProfiles(), last = lastProfile();
141
164
  if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
142
165
  const selected = await ctx.ui.select('OptChat profile', [...names, '+ Create profile']);
@@ -207,10 +230,17 @@ export default function optchat(pi: ExtensionAPI) {
207
230
  try {
208
231
  if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
209
232
  let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
233
+ // Only plain `pi -p` joins: JSON and RPC output carry Pi's own events, which a joined reply would bypass.
234
+ const connect = ctx.mode === 'print' ? connectMode() : 'off';
235
+ if (connect === 'join' && !name) { process.exitCode = 1; throw new Error('--optchat-connect join needs a profile: pass --optchat-profile'); }
210
236
  for (;;) {
211
237
  if (!name) { status(ctx); return; }
212
- try { await openProfile(name, ctx); break; }
213
- catch (error) {
238
+ try {
239
+ if (connect === 'join') joined = await joinProfile(name);
240
+ else await openProfile(name, ctx);
241
+ break;
242
+ } catch (error) {
243
+ if (error instanceof ProfileBusyError && connect === 'auto') { joined = await joinProfile(name); break; }
214
244
  if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
215
245
  // A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
216
246
  const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
@@ -222,7 +252,10 @@ export default function optchat(pi: ExtensionAPI) {
222
252
  if (name !== boundName) pi.appendEntry(binding, { name });
223
253
  } catch (error) {
224
254
  if (active) await stop().catch(() => {});
225
- fault = errorText(error); ctx.ui.notify(fault, 'error');
255
+ fault = errorText(error);
256
+ // 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.
257
+ if (ctx.mode !== 'tui') throw error;
258
+ ctx.ui.notify(fault, 'error');
226
259
  }
227
260
  // Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
228
261
  for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
@@ -232,6 +265,13 @@ export default function optchat(pi: ExtensionAPI) {
232
265
  pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
233
266
  pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
234
267
  pi.on('input', async (event, ctx) => {
268
+ if (joined) {
269
+ try {
270
+ if (event.images?.length) throw new Error('Joining a running profile accepts text only; provide a file path for the agent to read.');
271
+ await printReply(await joined.ask(event.text, ctx.cwd));
272
+ } catch (error) { process.stderr.write(`OptChat: ${errorText(error)}\n`); process.exitCode = 1; }
273
+ return { action: 'handled' };
274
+ }
235
275
  if (remote) {
236
276
  try {
237
277
  if (event.images?.length) throw new Error('Connected windows currently accept text only; provide a file path for the agent to read.');
@@ -239,10 +279,14 @@ export default function optchat(pi: ExtensionAPI) {
239
279
  } catch (error) { ctx.ui.notify(errorText(error), 'error'); ctx.ui.setEditorText(event.text); }
240
280
  return { action: 'handled' };
241
281
  }
242
- if (!active) { ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' }; }
282
+ if (!active) {
283
+ // Without a profile or flag, a headless run is plain Pi. A requested profile that failed to open refuses instead of running without memory.
284
+ if (ctx.mode !== 'tui' && !fault) return { action: 'continue' };
285
+ ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' };
286
+ }
243
287
  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
288
  if (event.source !== 'extension') {
245
- try { active.inbox.record(event.text); }
289
+ try { active.inbox.record(event.text, event.streamingBehavior !== undefined); }
246
290
  catch (error) { ctx.ui.notify(`Could not save input: ${errorText(error)}`, 'error'); return { action: 'handled' }; }
247
291
  }
248
292
  return { action: 'continue' };
@@ -259,24 +303,32 @@ export default function optchat(pi: ExtensionAPI) {
259
303
  if (active && !runStarted) startRun(ctx);
260
304
  });
261
305
  pi.on('before_agent_start', (event, ctx) => {
306
+ if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
262
307
  startRun(ctx);
263
308
  const a = required();
264
309
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
265
310
  syncSearch(a.config);
266
- event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`;
311
+ event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
267
312
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
268
313
  prompt = event.systemPrompt;
269
314
  });
270
- pi.on('message_end', (event, ctx) => {
315
+ pi.on('message_end', async (event, ctx) => {
271
316
  if (!active || !runStarted) return;
272
317
  const bounded = boundedMessage(event.message);
273
318
  const message = asUser(bounded);
319
+ // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
320
+ if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
321
+ try { await saveImages(active.memory.directory, message.content); }
322
+ catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
323
+ }
324
+ if (!active) return;
274
325
  if (message.role === 'user') {
275
326
  try {
276
327
  const text = textContent(message.content);
277
- if (reports.some(r => !r.batch && r.text === text)) receipts.set(message, reportReceipt(text));
328
+ // A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
329
+ if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) receipts.set(message, reportReceipt(text));
278
330
  else {
279
- // The inbox journaled the typed input: match without image placeholders or Pi's image notes.
331
+ // The inbox journaled the typed input: match without image references or Pi's image notes.
280
332
  const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
281
333
  let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
282
334
  ?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
@@ -293,12 +345,21 @@ export default function optchat(pi: ExtensionAPI) {
293
345
  });
294
346
  pi.on('context_with_system', async (event, ctx) => {
295
347
  try {
348
+ if (!active) return; // Plain Pi run: pass context through unmodified.
296
349
  const a = required();
297
350
  if (importing || pendingImport(a.dir)) throw new Error('Profile is unavailable while importing.');
298
351
  if (fault) throw new Error(fault);
299
352
  if (view === undefined) {
300
- ctx.ui.setWorkingMessage('Waiting for OptChat summaries…');
301
- try { await a.memory.settle(ctx.signal); } finally { ctx.ui.setWorkingMessage(); }
353
+ // A misconfigured summarizer retries forever, so say why instead of spinning silently.
354
+ let shown: string | undefined;
355
+ const show = () => {
356
+ const error = a.memory.lastError;
357
+ const text = `Waiting for OptChat summaries…${error ? ` failing: ${oneLine(error)}${error.includes('/optchat') ? '' : ' (see /optchat model)'}` : ''}`;
358
+ if (text !== shown) ctx.ui.setWorkingMessage(shown = text);
359
+ };
360
+ show();
361
+ const unsubscribe = a.memory.onChange(show);
362
+ try { await a.memory.settle(ctx.signal); } finally { unsubscribe(); ctx.ui.setWorkingMessage(); }
302
363
  view = a.memory.render(); // Capture old history before logging the new input.
303
364
  flush();
304
365
  }
@@ -324,7 +385,7 @@ export default function optchat(pi: ExtensionAPI) {
324
385
  pi.on('turn_end', (_event, ctx) => collectUsage(ctx));
325
386
  pi.on('agent_settled', async (_event, ctx) => {
326
387
  collectUsage(ctx);
327
- try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
388
+ try { flush(); active?.inbox.dropReturned(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
328
389
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
329
390
  runStarted = false; status(ctx);
330
391
  working = false; showTitle(ctx);
@@ -438,6 +499,7 @@ export default function optchat(pi: ExtensionAPI) {
438
499
  }
439
500
  if (action === 'profile') {
440
501
  if (!ctx.isIdle() || active?.children.active) throw new Error('Finish or stop active work before switching profiles.');
502
+ if (ctx.mode !== 'tui') throw new Error('/optchat profile requires interactive Pi. Start headless runs with --optchat-profile <name>.');
441
503
  const selected = await chooseProfile(ctx);
442
504
  if (!selected || selected === active?.name) return;
443
505
  await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: selected }); } });
@@ -459,7 +521,8 @@ export default function optchat(pi: ExtensionAPI) {
459
521
  }
460
522
  if (action === 'browse') {
461
523
  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'); });
524
+ // explorer.exe exits 1 even when it opened the file, so only its launch failing counts there.
525
+ 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
526
  ctx.ui.notify(file, 'info'); return;
464
527
  }
465
528
  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.