pi-optchat 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -3,12 +3,12 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
5
  import { createHash } from 'node:crypto';
6
- import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
6
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
7
7
  import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
8
  import { Type } from 'typebox';
9
9
  import { Memory } from './memory.ts';
10
10
  import { createCompressor } from './compactor.ts';
11
- import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
11
+ import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, THINKING, type ProfileConfig } from './profiles.ts';
12
12
  import { MASTER, VIEW_DOC } from './prompts.ts';
13
13
  import { cachePayload, record } from './cache.ts';
14
14
  import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
@@ -29,8 +29,12 @@ import { serveWindows } from './window-bridge.ts';
29
29
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
30
30
  import { createHandoffSummarizer } from './handoff.ts';
31
31
  import { mainTitle, TabTitle } from './title.ts';
32
+ import { showSettings } from './settings-page.ts';
32
33
 
33
34
  const binding = 'optchat.profile';
35
+ 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.';
36
+ /** A report run while idle reuses the last built prompt, so a Previous exchange change since then is applied here. */
37
+ const continuity = (prompt: string, on: boolean) => on === prompt.includes(CONTINUITY) ? prompt : on ? prompt.replace(VIEW_DOC, VIEW_DOC + CONTINUITY) : prompt.replace(CONTINUITY, '');
34
38
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
35
39
 
36
40
  export default function optchat(pi: ExtensionAPI) {
@@ -141,13 +145,13 @@ export default function optchat(pi: ExtensionAPI) {
141
145
  const memory = new Memory(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
142
146
  usage.compression(message, 'compactor', sessionId);
143
147
  status(ctx);
144
- }), warning => ctx.ui.notify(warning, 'error'));
148
+ }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
145
149
  openingMemory = memory;
146
150
  const inbox = new Inbox(dir);
147
151
  const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
148
152
  if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
149
153
  const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
150
- deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi),
154
+ deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi), settings: () => config,
151
155
  summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
152
156
  const loggedReports = new Set(memory.root.map(e => e.receipt));
153
157
  reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
@@ -227,7 +231,8 @@ export default function optchat(pi: ExtensionAPI) {
227
231
  });
228
232
  const startRun = (ctx: ExtensionContext) => {
229
233
  flush(); run = []; logged = 0; view = undefined; runStarted = true;
230
- previous = previousExchange(ctx.sessionManager.getBranch());
234
+ const config = active?.config;
235
+ previous = config?.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), config.previousExchangeKB * 1000) : [];
231
236
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
232
237
  };
233
238
  // A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
@@ -239,7 +244,7 @@ export default function optchat(pi: ExtensionAPI) {
239
244
  startRun(ctx);
240
245
  const a = required();
241
246
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
242
- event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.`;
247
+ event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}`;
243
248
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
244
249
  prompt = event.systemPrompt;
245
250
  });
@@ -274,11 +279,11 @@ export default function optchat(pi: ExtensionAPI) {
274
279
  if (fault) throw new Error(fault);
275
280
  if (view === undefined) {
276
281
  ctx.ui.setWorkingMessage('Waiting for OptChat summaries…');
277
- await a.memory.settle(ctx.signal);
282
+ try { await a.memory.settle(ctx.signal); } finally { ctx.ui.setWorkingMessage(); }
278
283
  view = a.memory.render(); // Capture old history before logging the new input.
279
- flush(); ctx.ui.setWorkingMessage();
284
+ flush();
280
285
  }
281
- return { messages: buildContext(event.messages, run, view, prompt, previous) };
286
+ return { messages: buildContext(event.messages, run, view, continuity(prompt, a.config.previousExchange), previous) };
282
287
  } catch (error) {
283
288
  // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
284
289
  ctx.abort();
@@ -314,8 +319,8 @@ export default function optchat(pi: ExtensionAPI) {
314
319
  registerReportRenderer(pi);
315
320
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
316
321
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
317
- description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
318
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
322
+ description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
323
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
319
324
  async execute(_id, args, signal, _update, ctx) {
320
325
  const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
321
326
  return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
@@ -332,10 +337,11 @@ export default function optchat(pi: ExtensionAPI) {
332
337
  choices.sort((a, b) => Number(b === `${current.provider}/${current.model}`) - Number(a === `${current.provider}/${current.model}`) || a.localeCompare(b));
333
338
  const selected = await ctx.ui.select(`${a.name}: ${role} model`, choices);
334
339
  if (!selected) return;
335
- const thinking = await ctx.ui.select('Thinking level', ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
340
+ const picked = await ctx.ui.select('Thinking level', [...THINKING]);
341
+ const thinking = THINKING.find(level => level === picked);
336
342
  if (!thinking) return;
337
343
  const separator = selected.indexOf('/');
338
- a.config[role] = { provider: selected.slice(0, separator), model: selected.slice(separator + 1), thinking: thinking as ThinkingLevel };
344
+ a.config[role] = { provider: selected.slice(0, separator), model: selected.slice(separator + 1), thinking };
339
345
  saveConfig(a.dir, a.config); ctx.ui.notify(`${role}: ${selected} (${thinking}); applies to new calls.`, 'info');
340
346
  };
341
347
  const inspect = async (ctx: ExtensionContext, page: InspectorPage) => {
@@ -364,7 +370,7 @@ export default function optchat(pi: ExtensionAPI) {
364
370
  if (!action) {
365
371
  const a = active;
366
372
  const info = a ? `${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending\nCompactor: ${a.config.compactor.model} (${a.config.compactor.thinking})\nAgents: ${a.config.subagent.model} (${a.config.subagent.thinking})\n${a.memory.lastError ?? ''}` : 'No active profile';
367
- action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'model', 'agents', 'usage', 'instructions', 'browse', 'import']) ?? '';
373
+ action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'instructions', 'browse', 'import']) ?? '';
368
374
  }
369
375
  if (action === 'import') {
370
376
  const a = required();
@@ -394,7 +400,7 @@ export default function optchat(pi: ExtensionAPI) {
394
400
  if (!closed) { await a.memory.close(); closed = true; }
395
401
  const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
396
402
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
397
- });
403
+ }, () => a.config.summaryAcceptBytes);
398
404
  const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, progress), signal);
399
405
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
400
406
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
@@ -414,6 +420,12 @@ export default function optchat(pi: ExtensionAPI) {
414
420
  await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: selected }); } });
415
421
  return;
416
422
  }
423
+ if (action === 'settings') {
424
+ const a = required();
425
+ if (ctx.mode !== 'tui') throw new Error('/optchat settings requires interactive Pi. Edit config.json in the profile directory instead.');
426
+ return showSettings(ctx, { profile: a.name, config: a.config, models: ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`).sort(),
427
+ save: config => saveConfig(a.dir, config) });
428
+ }
417
429
  if (action === 'model') return pickModel(ctx, 'compactor');
418
430
  if (action === 'agents model') return pickModel(ctx, 'subagent');
419
431
  if (action === 'agents' || action === 'usage') return inspect(ctx, action);
@@ -428,7 +440,7 @@ export default function optchat(pi: ExtensionAPI) {
428
440
  if (ctx.hasUI) execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error) ctx.ui.notify(`Open ${file}`, 'info'); });
429
441
  ctx.ui.notify(file, 'info'); return;
430
442
  }
431
- if (action) throw new Error('Use /optchat [profile|model|agents|usage|instructions|browse|import].');
443
+ if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|instructions|browse|import].');
432
444
  };
433
445
  pi.registerCommand('complete', { description: 'End this connected conversation and hand off to the main agent', handler: async (_args, ctx) => {
434
446
  if (!remote) { ctx.ui.notify('/complete is for connected subagent windows.', 'info'); return; }
@@ -438,8 +450,8 @@ export default function optchat(pi: ExtensionAPI) {
438
450
  if (!remote) { ctx.ui.notify('/tell-main is for connected subagent windows.', 'info'); return; }
439
451
  try { await remote.tell(args); } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
440
452
  } });
441
- pi.registerCommand('optchat', { description: 'OptChat profiles, models, agents, instructions, memory browser, and imports',
442
- getArgumentCompletions: prefix => ['profile', 'model', 'agents', 'agents model', 'usage', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
453
+ pi.registerCommand('optchat', { description: 'OptChat profiles, settings, models, agents, instructions, memory browser, and imports',
454
+ getArgumentCompletions: prefix => ['profile', 'settings', 'model', 'agents', 'agents model', 'usage', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
443
455
  handler: async (args, ctx) => { try { await command(args, ctx); } catch (error) { ctx.ui.notify(errorText(error), 'error'); } },
444
456
  });
445
457
  }
package/src/memory.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { EventEmitter } from 'node:events';
4
4
 
5
5
  export const NODE = 512;
@@ -19,21 +19,32 @@ export const end = (part: Part) => start(part) + 2 ** part.l;
19
19
  export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
20
20
  const UNBUILT_BYTES = bytes(UNBUILT);
21
21
  export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
22
+ const lineBytes = (s: string) => bytes(flat(s));
23
+ const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
24
+ const VIEW_OPEN = '<chat>\n';
25
+ export const isView = (text: string) => text.startsWith(VIEW_OPEN);
22
26
  export function cap(text: string, limit = CAP) {
23
27
  if (text.length <= limit) return text;
24
- const notice = `\n[${text.length - limit} characters omitted; head and tail retained]\n`;
25
- const half = Math.floor((limit - notice.length) / 2);
26
- return text.slice(0, half) + notice + text.slice(-half);
28
+ const half = Math.floor((limit - notice(text.length).length) / 2);
29
+ const head = text.slice(0, /[\ud800-\udbff]/.test(text[half - 1]) ? half - 1 : half);
30
+ const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
31
+ return head + notice(text.length - head.length - tail.length) + tail;
27
32
  }
28
33
  export function localDay(date = new Date()) {
29
34
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
30
35
  }
31
- export function appendJson(file: string, value: unknown) {
32
- const fd = openSync(file, 'a', 0o600);
36
+ const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
37
+ /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
38
+ export function appendJson(file: string, value: unknown, size?: number) {
39
+ const fd = openSync(file, 'a+', 0o600);
33
40
  try {
34
- const data = Buffer.from(JSON.stringify(value) + '\n');
41
+ const length = fstatSync(fd).size, last = Buffer.alloc(1);
42
+ if (size !== undefined && length !== size) throw otherWriter(file);
43
+ const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
44
+ const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
35
45
  if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
36
46
  fsyncSync(fd);
47
+ return fstatSync(fd).size;
37
48
  } finally { closeSync(fd); }
38
49
  }
39
50
  function records(dir: string, warn: (s: string) => void): unknown[] {
@@ -41,16 +52,11 @@ function records(dir: string, warn: (s: string) => void): unknown[] {
41
52
  const result: unknown[] = [];
42
53
  for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
43
54
  const file = join(dir, name);
44
- const text = readFileSync(file, 'utf8');
45
- for (const [index, line] of text.split('\n').entries()) {
55
+ for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
46
56
  if (!line.trim()) continue;
47
57
  try { result.push(JSON.parse(line)); }
48
58
  catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
49
59
  }
50
- if (text && !text.endsWith('\n')) {
51
- const fd = openSync(file, 'a');
52
- try { writeSync(fd, '\n'); fsyncSync(fd); } finally { closeSync(fd); }
53
- }
54
60
  }
55
61
  return result;
56
62
  }
@@ -77,6 +83,7 @@ export class Memory {
77
83
  private readonly busy = new Map<number, Promise<void>>();
78
84
  private readonly retryAt = new Map<number, number>();
79
85
  private readonly reported = new Set<number>();
86
+ private readonly lastSeenBytes = new Map<string, number>();
80
87
  private viewBytes = 0;
81
88
  private leaves = 0;
82
89
  /** Per level, every node below this index is built. */
@@ -90,24 +97,31 @@ export class Memory {
90
97
  private readonly warn: (s: string) => void = console.error,
91
98
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
92
99
  for (const sub of ['main', 'tree']) mkdirSync(join(directory, sub), { recursive: true, mode: 0o700 });
93
- for (const value of records(join(directory, 'main'), warn)) {
94
- if (!isEntry(value) || value.i !== this.root.length) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
95
- this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
96
- }
100
+ const main = join(directory, 'main'), log = records(main, warn);
101
+ for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
102
+ const entries = log.every(isEntry) ? log.sort((a, b) => a.i - b.i) : undefined;
103
+ if (!entries || entries.some((entry, i) => entry.i !== i)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
104
+ for (const value of entries) this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
97
105
  for (const value of records(join(directory, 'tree'), warn)) {
98
106
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
99
107
  throw new Error('Invalid OptChat summary record.');
100
108
  if (value.l === 0 && !this.tree.has(key(value))) this.leaves++;
101
- this.tree.set(key(value), { ...value, size: bytes(value.text) });
109
+ this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
102
110
  }
103
111
  // Fold history in order; do not retile the entire log on each turn.
104
112
  for (let i = 0; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
105
113
  this.schedule();
106
114
  }
115
+ private checkLog(next: string) {
116
+ const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
117
+ if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
118
+ }
107
119
  append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string) {
108
120
  if (this.stopped) throw new Error('Memory is closed.');
109
121
  const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}) };
110
- appendJson(join(this.directory, 'main', `${localDay()}.jsonl`), entry);
122
+ const file = join(this.directory, 'main', `${localDay()}.jsonl`);
123
+ if (!this.lastSeenBytes.has(file)) this.checkLog(file);
124
+ this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
111
125
  this.root.push(entry); this.push(entry.i); this.fit(); this.schedule();
112
126
  return entry;
113
127
  }
@@ -115,7 +129,7 @@ export class Memory {
115
129
  private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
116
130
  private partBytes(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
117
131
  private push(i: number) { const part = { l: 0, i }; this.view.push(part); this.viewBytes += this.partBytes(part); }
118
- render() { return `<chat>\n${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
132
+ render() { return `${VIEW_OPEN}${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
119
133
  get ready() { return this.view.every(p => this.node(p)); }
120
134
  get pending() { return this.root.length - this.leaves; }
121
135
  get active() { return this.busy.size; }
@@ -192,7 +206,7 @@ export class Memory {
192
206
  historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin) }, this.controller.signal)).trim();
193
207
  if (this.stopped) return;
194
208
  if (!text) throw new Error('Compactor returned an empty summary.');
195
- const node = { ...part, text, size: bytes(text) };
209
+ const node = { ...part, text, size: lineBytes(text) };
196
210
  appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
197
211
  this.tree.set(key(part), node); this.retryAt.delete(key(part));
198
212
  // A leaf is usually still in the view when built, but after a damaged tree file a saved parent can already hide it.
@@ -200,8 +214,9 @@ export class Memory {
200
214
  if (!this.retryAt.size) this.lastError = undefined;
201
215
  this.fit();
202
216
  }
217
+ /** Waits until every part of the view is built. As in the recipe, the view may run over budget until pending merges land. */
203
218
  async settle(signal?: AbortSignal, all = false): Promise<void> {
204
- const done = () => this.ready && this.size <= this.budget && (!all || (this.busy.size === 0 && this.tree.size === this.expectedNodes()));
219
+ const done = () => this.ready && (!all || (this.busy.size === 0 && this.tree.size === this.expectedNodes()));
205
220
  if (done()) return;
206
221
  if (this.stopped || signal?.aborted) throw new Error('Memory wait cancelled.');
207
222
  await new Promise<void>((resolve, reject) => {
package/src/profiles.ts CHANGED
@@ -1,17 +1,19 @@
1
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { createConnection, createServer } from 'node:net';
3
- import { homedir, tmpdir } from 'node:os';
3
+ import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
- import { createHash } from 'node:crypto';
6
5
  import type { ModelChoice } from './compactor.ts';
7
6
  import { record } from './cache.ts';
7
+ import { DEFAULT_SETTINGS, readSettings, type Settings } from './settings.ts';
8
8
 
9
9
  export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
10
- export interface ProfileConfig { compactor: ModelChoice; subagent: ModelChoice }
10
+ export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice }
11
11
  export const defaults: ProfileConfig = {
12
12
  compactor: { provider: 'anthropic', model: 'claude-sonnet-5-5', thinking: 'medium' },
13
13
  subagent: { provider: 'anthropic', model: 'claude-opus-5-5', thinking: 'high' },
14
+ ...DEFAULT_SETTINGS,
14
15
  };
16
+ export const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
15
17
  export function profilePath(name: string) {
16
18
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) throw new Error('Profile names: 1–64 lowercase letters, digits, hyphens, or underscores.');
17
19
  return join(dataHome(), 'profiles', name);
@@ -40,12 +42,13 @@ export function createProfile(name: string) {
40
42
  export function saveConfig(dir: string, config: ProfileConfig) { atomicWrite(join(dir, 'config.json'), JSON.stringify(config, null, 2) + '\n'); }
41
43
  function modelChoice(value: unknown): value is ModelChoice {
42
44
  return record(value) && typeof value.provider === 'string' && typeof value.model === 'string'
43
- && typeof value.thinking === 'string' && ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(value.thinking);
45
+ && THINKING.some(level => level === value.thinking);
44
46
  }
45
47
  export function loadConfig(dir: string): ProfileConfig {
46
48
  const value: unknown = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'));
47
49
  if (!record(value) || !modelChoice(value.compactor) || !modelChoice(value.subagent)) throw new Error(`Invalid profile config: ${dir}/config.json`);
48
- return { compactor: value.compactor, subagent: value.subagent };
50
+ try { return { compactor: value.compactor, subagent: value.subagent, ...readSettings(value) }; }
51
+ catch (error) { throw new Error(`Invalid profile config: ${dir}/config.json: ${error instanceof Error ? error.message : String(error)}`); }
49
52
  }
50
53
  export function instructions(dir: string) { return readFileSync(join(dir, 'AGENTS.md'), 'utf8'); }
51
54
  export function lastProfile() {
@@ -57,15 +60,29 @@ export function rememberProfile(name: string) { atomicWrite(join(dataHome(), 'la
57
60
  export class ProfileBusyError extends Error {
58
61
  constructor(readonly owner: string) { super(`Profile already running: ${owner}`); }
59
62
  }
60
- export function profileSocket(dir: string, purpose: 'lock' | 'windows' = 'lock') {
61
- const hash = createHash('sha256').update(dir).digest('hex').slice(0, 24);
62
- return join(tmpdir(), `pi-optchat-${process.getuid?.() ?? 'user'}-${hash}${purpose === 'lock' ? '' : '-windows'}.sock`);
63
+ /** Lives in the profile itself, so every Pi on this profile finds the same socket whatever its TMPDIR. Git skips sockets, so checkpoints never see it. */
64
+ export const profileSocket = (dir: string, purpose: 'lock' | 'windows' = 'lock') => join(dir, `${purpose}.sock`);
65
+
66
+ // sun_path is 104 bytes on macOS and 108 elsewhere, both including the NUL. Node 22 binds a truncated path instead of failing, so the length is checked before listen.
67
+ export const SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 103 : 107;
68
+ export function checkSocketPath(path: string) {
69
+ const length = Buffer.byteLength(path);
70
+ if (length > SOCKET_PATH_LIMIT) throw new Error(`Cannot listen on the profile socket: its path is ${length} bytes, over this system's limit of ${SOCKET_PATH_LIMIT}. Set OPTCHAT_HOME to a shorter directory: ${path}`);
71
+ }
72
+
73
+ /** A socket that may be replaced; a file that only shares its name is refused, never deleted. */
74
+ function existingSocket(path: string) {
75
+ let stat; try { stat = lstatSync(path); } catch { return undefined; }
76
+ if (!stat.isSocket()) throw new Error(`${path} is not an OptChat socket. Move it out of the profile and try again.`);
77
+ return stat;
63
78
  }
79
+ /** Removes a dead socket left by a previous owner. */
80
+ export function removeStaleSocket(path: string) { if (existingSocket(path)) unlinkSync(path); }
64
81
 
65
82
  /** OS-owned socket lifetime, no timeout-based stealing of a busy profile. */
66
83
  export async function lockProfile(dir: string, description: string) {
67
- const socketPath = profileSocket(dir);
68
- const server = createServer(socket => { socket.end(description); });
84
+ const socketPath = profileSocket(dir); checkSocketPath(socketPath);
85
+ const server = createServer(socket => { socket.on('error', () => socket.destroy()); socket.end(description); });
69
86
  const listen = () => new Promise<void>((resolve, reject) => {
70
87
  const failed = (error: Error) => { server.off('listening', ready); reject(error); };
71
88
  const ready = () => { server.off('error', failed); resolve(); };
@@ -74,7 +91,8 @@ export async function lockProfile(dir: string, description: string) {
74
91
  try { await listen(); }
75
92
  catch (error) {
76
93
  if (!(error instanceof Error) || !('code' in error) || error.code !== 'EADDRINUSE') throw error;
77
- const before = statSync(socketPath);
94
+ const before = existingSocket(socketPath);
95
+ if (!before) throw new Error('Profile lock changed; try again.');
78
96
  const owner = await new Promise<string | undefined>((resolve, reject) => {
79
97
  const socket = createConnection(socketPath); let message = '';
80
98
  socket.setTimeout(1500, () => { socket.destroy(); reject(new Error('Profile lock did not respond; refusing to steal it.')); });
@@ -86,6 +104,7 @@ export async function lockProfile(dir: string, description: string) {
86
104
  if (statSync(socketPath).ino !== before.ino) throw new Error('Profile lock changed; try again.');
87
105
  unlinkSync(socketPath); await listen();
88
106
  }
89
- chmodSync(socketPath, 0o600);
90
- return () => new Promise<void>(resolve => server.close(() => resolve()));
107
+ const unlock = () => new Promise<void>(resolve => server.close(() => resolve()));
108
+ try { chmodSync(socketPath, 0o600); } catch (error) { await unlock(); throw error; }
109
+ return unlock;
91
110
  }
package/src/runs.ts CHANGED
@@ -19,6 +19,18 @@ export interface RunInfo {
19
19
  /** `from` is missing on runs saved before senders were recorded. */
20
20
  guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
21
21
  }
22
+ const moves: Record<RunState, readonly RunState[]> = {
23
+ running: ['waiting', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
24
+ waiting: ['running', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
25
+ stopping: ['stopped', 'failed', 'interrupted'],
26
+ // A finished run only takes the outcome of a connected handoff.
27
+ completed: ['interrupted'], failed: ['completed', 'interrupted'], stopped: ['completed', 'interrupted'], interrupted: ['completed'],
28
+ };
29
+ /** Every run state change goes through here, so a late stop cannot rewrite a finished run. Returns whether the run is now in `to` (true for a repeat, so a second stop can still abort a child that ignored the first). */
30
+ export function transition(run: RunInfo, to: RunState) {
31
+ if (run.state !== to && !moves[run.state].includes(to)) return false;
32
+ run.state = to; return true;
33
+ }
22
34
  export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping';
23
35
  function isRun(value: unknown): value is RunInfo {
24
36
  return record(value) && ['id', 'task', 'cwd', 'model', 'thinking', 'parentSession'].every(k => typeof value[k] === 'string')
@@ -31,7 +43,7 @@ function isRun(value: unknown): value is RunInfo {
31
43
  && (value.handoff === undefined || record(value.handoff) && ['complete', 'disconnected', 'owner-stopped', 'failed'].includes(String(value.handoff.reason))
32
44
  && (value.handoff.text === undefined || typeof value.handoff.text === 'string')
33
45
  && (value.handoff.delivered === undefined || typeof value.handoff.delivered === 'boolean'))
34
- && typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1 && value.depth <= 3
46
+ && typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1
35
47
  && (value.parentId === undefined || typeof value.parentId === 'string')
36
48
  && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager'));
37
49
  }
@@ -57,7 +69,7 @@ export class RunHistory {
57
69
  this.records.set(run.id, run);
58
70
  if (isActiveRun(run)) {
59
71
  if (run.connected) run.handoff ??= { reason: 'owner-stopped' };
60
- run.state = 'interrupted'; run.ended = Date.now();
72
+ transition(run, 'interrupted'); run.ended = Date.now();
61
73
  run.report = 'Pi closed before this agent finished. Its partial transcript is retained.';
62
74
  for (const g of run.guidance) if (g.state === 'queued') g.state = 'undelivered';
63
75
  this.save(run);
@@ -0,0 +1,160 @@
1
+ import { Container, fuzzyFilter, getKeybindings, Input, SelectList, SettingsList, Spacer, Text, type Component, type SelectItem, type SettingItem } from '@earendil-works/pi-tui';
2
+ import { DynamicBorder, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
3
+ import { defaults, THINKING, type ProfileConfig } from './profiles.ts';
4
+ import { invalid, isNumberKey, SETTING_KEYS, SETTINGS, type NumberKey, type SettingKey } from './settings.ts';
5
+ import type { ModelChoice } from './compactor.ts';
6
+
7
+ type Role = 'compactor' | 'subagent';
8
+ const ROLES: Record<Role, { label: string; description: string; applies: string }> = {
9
+ compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and connected-window handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
10
+ subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
11
+ };
12
+ const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
13
+ const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
14
+ /** The value column: the model, then whether it is the default (shown short, without its provider). */
15
+ const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
16
+ ? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
17
+ const show = (key: SettingKey, value: number | boolean) => {
18
+ const spec = SETTINGS[key];
19
+ return typeof value === 'boolean' ? value ? 'on' : 'off' : spec.kind === 'number' && spec.unit ? `${value} ${spec.unit}` : String(value);
20
+ };
21
+
22
+ interface Options {
23
+ profile: string; config: ProfileConfig; models: string[];
24
+ /** Saves the whole profile config; throws if it can't be written. */
25
+ save: (config: ProfileConfig) => void;
26
+ }
27
+
28
+ /** A titled step inside the page: what it changes, the control, and its keys. */
29
+ function step(theme: Theme, title: string, description: string, control: Component, hint: string, extra: Component[] = []) {
30
+ const box = new Container();
31
+ for (const child of [new Text(theme.bold(theme.fg('accent', title)), 0, 0), new Spacer(1), new Text(theme.fg('muted', description), 0, 0),
32
+ new Spacer(1), control, ...extra, new Spacer(1), new Text(theme.fg('dim', hint), 0, 0)]) box.addChild(child);
33
+ return box;
34
+ }
35
+ const listTheme = (theme: Theme) => ({ selectedPrefix: (t: string) => theme.fg('accent', t), selectedText: (t: string) => theme.fg('accent', t),
36
+ description: (t: string) => theme.fg('muted', t), scrollInfo: (t: string) => theme.fg('muted', t),
37
+ noMatch: () => theme.fg('muted', ' No matching models. Pi lists the models you are logged in to.') });
38
+
39
+ /** Whole numbers only: typing replaces the value, Enter saves it, a bad value stays on screen with the reason. */
40
+ class NumberStep extends Container {
41
+ private readonly input: Input;
42
+ private readonly error = new Text('', 0, 0);
43
+ constructor(theme: Theme, key: NumberKey, current: number, submit: (value: number) => string | undefined, cancel: () => void) {
44
+ super();
45
+ const spec = SETTINGS[key];
46
+ // Empty with the current value as placeholder: typing starts a new value, Enter alone keeps the old one.
47
+ this.input = new Input({ placeholder: String(current), placeholderStyle: text => theme.fg('dim', text) }); this.input.focused = true;
48
+ this.input.onEscape = cancel;
49
+ this.input.onSubmit = text => {
50
+ if (!text.trim()) return cancel();
51
+ const value = /^\s*\d+\s*$/.test(text) ? Number(text) : NaN;
52
+ const problem = invalid(key, value) ?? submit(value);
53
+ if (problem) this.error.setText(theme.fg('error', problem));
54
+ };
55
+ this.addChild(step(theme, `${spec.label}${spec.unit ? ` (${spec.unit})` : ''}`, `${spec.description} Default ${show(key, spec.default)}.`,
56
+ this.input, 'Type a number · Enter to save · Esc to go back', [this.error]));
57
+ }
58
+ handleInput(data: string) { this.input.handleInput(data); }
59
+ }
60
+
61
+ /** Model first (type to filter), then thinking level; Esc goes back a step. */
62
+ class ModelStep extends Container {
63
+ private active: { handleInput(data: string): void } = { handleInput: () => {} };
64
+ constructor(private readonly theme: Theme, private readonly role: Role, private readonly models: string[], private readonly current: ModelChoice,
65
+ private readonly submit: (choice: ModelChoice) => string | undefined, private readonly cancel: () => void) {
66
+ super();
67
+ this.pickModel();
68
+ }
69
+ private pickModel() {
70
+ const { theme, current } = this, filter = new Input();
71
+ const items: SelectItem[] = this.models.map(value => ({ value, label: value }));
72
+ let list = new SelectList(items, 10, listTheme(theme));
73
+ const build = () => {
74
+ list = new SelectList(filter.getValue() ? fuzzyFilter(items, filter.getValue(), i => i.value) : items, 10, listTheme(theme));
75
+ list.onSelect = item => this.pickThinking(item.value); list.onCancel = this.cancel;
76
+ return list;
77
+ };
78
+ build(); list.setSelectedIndex(Math.max(0, this.models.indexOf(modelName(current))));
79
+ filter.focused = true;
80
+ const box = step(theme, ROLES[this.role].label, `${ROLES[this.role].description} Now ${showModel(current)}.`, filter, 'Type to filter · Enter to choose · Esc to go back', [new Spacer(1), list]);
81
+ this.show(box, data => {
82
+ const keys = getKeybindings();
83
+ if ((['tui.select.up', 'tui.select.down', 'tui.select.confirm', 'tui.select.cancel'] as const).some(k => keys.matches(data, k))) return list.handleInput(data);
84
+ filter.handleInput(data);
85
+ const index = box.children.indexOf(list); box.children[index] = build();
86
+ });
87
+ }
88
+ private pickThinking(model: string) {
89
+ const { theme, current } = this, error = new Text('', 0, 0);
90
+ const list = new SelectList(THINKING.map(value => ({ value, label: value })), THINKING.length, listTheme(theme));
91
+ list.setSelectedIndex(Math.max(0, THINKING.indexOf(current.thinking)));
92
+ list.onCancel = () => this.pickModel();
93
+ list.onSelect = item => {
94
+ const thinking = THINKING.find(level => level === item.value);
95
+ const slash = model.indexOf('/');
96
+ const problem = thinking && this.submit({ provider: model.slice(0, slash), model: model.slice(slash + 1), thinking });
97
+ if (problem) error.setText(theme.fg('error', problem));
98
+ };
99
+ this.show(step(theme, 'Thinking level', `For ${model}.`, list, 'Enter to save · Esc to go back', [error]), data => list.handleInput(data));
100
+ }
101
+ private show(box: Component, input: (data: string) => void) { this.clear(); this.addChild(box); this.active = { handleInput: input }; }
102
+ handleInput(data: string) { this.active.handleInput(data); }
103
+ }
104
+
105
+ /** The page itself: Pi's settings list over this profile's config, one row per setting, saved on change. */
106
+ export function settingsPage(theme: Theme, o: Options, close: () => void, redraw = () => {}) {
107
+ const { config } = o;
108
+ const notice = new Text('', 2, 0);
109
+ const marker = (same: boolean, fallback: string) => theme.fg('dim', same ? ' default' : ` default ${fallback}`);
110
+ const value = (key: SettingKey, current: number | boolean) => `${show(key, current)}${marker(current === SETTINGS[key].default, show(key, SETTINGS[key].default))}`;
111
+ const apply = (patch: Partial<ProfileConfig>, what: string, applies: string) => {
112
+ try { o.save({ ...config, ...patch }); }
113
+ catch (error) { return `Could not save: ${error instanceof Error ? error.message : String(error)}`; }
114
+ Object.assign(config, patch);
115
+ notice.setText(theme.fg('success', `Saved ${what}. `) + theme.fg('muted', applies));
116
+ return undefined;
117
+ };
118
+ const models = (['compactor', 'subagent'] as const).map((role): SettingItem => {
119
+ const { label, description, applies } = ROLES[role];
120
+ return { id: role, label, description: `${description} Default ${showModel(defaults[role])}. ${applies}`,
121
+ currentValue: modelValue(theme, role, config[role]),
122
+ submenu: (_value, done) => new ModelStep(theme, role, o.models, config[role], choice => {
123
+ const problem = apply({ [role]: choice }, `${label.toLowerCase()} ${showModel(choice)}`, applies);
124
+ if (!problem) done(modelValue(theme, role, choice));
125
+ return problem;
126
+ }, () => done()),
127
+ };
128
+ });
129
+ const settings = SETTING_KEYS.map((key): SettingItem => {
130
+ const spec = SETTINGS[key], base = { id: key, label: spec.label, description: `${spec.description} ${spec.applies}` };
131
+ if (isNumberKey(key)) return { ...base, currentValue: value(key, config[key]),
132
+ submenu: (_value, done) => new NumberStep(theme, key, config[key], next => {
133
+ const problem = apply({ [key]: next }, `${spec.label.toLowerCase()} ${show(key, next)}`, spec.applies);
134
+ if (!problem) done(value(key, next));
135
+ return problem;
136
+ }, () => done()) };
137
+ return { ...base, currentValue: value(key, config[key]), values: [value(key, true), value(key, false)] };
138
+ });
139
+ const list = new SettingsList([...models, ...settings], 10, {
140
+ label: (text, selected) => selected ? theme.fg('accent', text) : text,
141
+ value: (text, selected) => selected ? theme.fg('accent', text) : theme.fg('muted', text),
142
+ description: text => theme.fg('dim', text), cursor: theme.fg('accent', '→ '), hint: text => theme.fg('dim', text),
143
+ }, (id, next) => {
144
+ // Only the on/off rows change here; the others save from their own step.
145
+ const key = SETTING_KEYS.find(k => k === id);
146
+ if (!key || isNumberKey(key)) return;
147
+ const on = next === value(key, true);
148
+ const problem = apply({ [key]: on }, `${SETTINGS[key].label.toLowerCase()} ${on ? 'on' : 'off'}`, SETTINGS[key].applies);
149
+ if (problem) { list.updateValue(key, value(key, config[key])); notice.setText(theme.fg('error', problem)); }
150
+ }, close);
151
+ const page = new Container();
152
+ for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', 'OptChat settings'))}${theme.fg('muted', ` · ${o.profile}`)}`, 1, 0),
153
+ new Text(theme.fg('dim', 'Saved as you change them · Victor\'s recipe by default, except Previous exchange and Summary size tolerance'), 1, 0),
154
+ new Spacer(1), list, notice, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
155
+ return Object.assign(page, { handleInput: (data: string) => { notice.setText(''); list.handleInput(data); redraw(); } });
156
+ }
157
+
158
+ export function showSettings(ctx: ExtensionContext, o: Options) {
159
+ return ctx.ui.custom<void>((tui, theme, _keys, done) => settingsPage(theme, o, () => done(undefined), () => tui.requestRender()));
160
+ }