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.
- package/README.md +32 -19
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/docs/victor-recipe.md +5 -5
- package/package.json +2 -1
- package/src/agents.ts +32 -4
- package/src/browser.ts +5 -3
- package/src/cache.ts +14 -10
- package/src/checkpoint.ts +1 -1
- package/src/compactor.ts +19 -10
- package/src/images.ts +44 -0
- package/src/import/job.ts +12 -7
- package/src/import/sources.ts +108 -14
- package/src/import/ui.ts +6 -4
- package/src/inbox.ts +15 -4
- package/src/index.ts +89 -26
- package/src/inspector.ts +19 -2
- package/src/memory.ts +197 -79
- package/src/profiles.ts +51 -32
- package/src/prompts.ts +4 -4
- package/src/runs.ts +9 -2
- package/src/settings-page.ts +1 -1
- package/src/settings.ts +3 -3
- package/src/tools.ts +13 -4
- package/src/transcript.ts +26 -9
- package/src/window-bridge.ts +76 -17
package/src/import/sources.ts
CHANGED
|
@@ -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
|
-
|
|
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 === '
|
|
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'
|
|
142
|
-
const first = !user ? '' : source === '
|
|
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
|
|
167
|
-
|
|
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'
|
|
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.
|
|
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() +
|
|
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 './
|
|
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();
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
213
|
-
|
|
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);
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|