pi-optchat 0.6.6 → 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/README.md +34 -9
- package/docs/victor-recipe.md +2 -1
- package/package.json +3 -2
- package/src/agent-view.ts +23 -6
- package/src/agents.ts +57 -33
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +49 -9
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +30 -15
- package/src/index.ts +30 -18
- package/src/inspector.ts +41 -19
- package/src/memory.ts +38 -23
- package/src/profiles.ts +33 -14
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +160 -0
- package/src/settings.ts +42 -0
- package/src/transcript.ts +15 -4
- package/src/usage.ts +11 -11
- package/src/window-bridge.ts +4 -4
package/src/import/sources.ts
CHANGED
|
@@ -22,9 +22,12 @@ const codexSubagent = (metadata: Record<string, unknown>) => metadata.source ===
|
|
|
22
22
|
|| record(metadata.source) && 'subagent' in metadata.source;
|
|
23
23
|
const missingSource = (error: unknown) => record(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
24
24
|
const missingWarning = (file: string) => `${file}: source file is no longer available; conversation skipped. Rescan to retry if it returns.`;
|
|
25
|
+
const zoneless = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
|
|
26
|
+
// A time without a zone is UTC, not the machine's zone. A number above 1e11 is milliseconds (1e11 seconds is the year 5138).
|
|
25
27
|
export function timestamp(value: unknown, fallback: string): string {
|
|
26
|
-
const time = typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN;
|
|
27
|
-
|
|
28
|
+
const time = typeof value === 'number' ? (value > 1e11 ? value : value * 1000) : typeof value === 'string' ? Date.parse(value.trim().replace(zoneless, '$1T$2Z')) : NaN;
|
|
29
|
+
const date = new Date(time);
|
|
30
|
+
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
|
|
28
31
|
}
|
|
29
32
|
function text(value: unknown): string {
|
|
30
33
|
if (typeof value === 'string') return value;
|
|
@@ -188,10 +191,19 @@ async function claudeProject(folder: string, name: string, signal?: AbortSignal)
|
|
|
188
191
|
function frontmatter(content: string) {
|
|
189
192
|
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
|
|
190
193
|
const fields = new Map<string, string>();
|
|
191
|
-
|
|
194
|
+
const lines = match ? match[1].split(/\r?\n/) : [];
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
196
|
// Newer files nest type/modified under `metadata:`; the first occurrence of each key wins.
|
|
193
|
-
const m =
|
|
194
|
-
if (
|
|
197
|
+
const m = /^(\s*)([A-Za-z]+):\s*(.+?)\s*$/.exec(lines[i]);
|
|
198
|
+
if (!m) continue;
|
|
199
|
+
let value: string;
|
|
200
|
+
if (/^[>|][+-]?\d?$/.test(m[3])) { // A folded or literal block is the indented lines after the key.
|
|
201
|
+
let end = i + 1;
|
|
202
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > m[1].length)) end++;
|
|
203
|
+
value = lines.slice(i + 1, end).map(line => line.trim()).join(m[3].startsWith('>') ? ' ' : '\n').trim();
|
|
204
|
+
i = end - 1;
|
|
205
|
+
} else value = unquote(m[3]);
|
|
206
|
+
if (value && !fields.has(m[2])) fields.set(m[2], value);
|
|
195
207
|
}
|
|
196
208
|
return { fields, body: match ? content.slice(match[0].length) : content };
|
|
197
209
|
}
|
|
@@ -233,16 +245,19 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
|
|
|
233
245
|
const nodes = Object.entries(mapping).filter((pair): pair is [string, Record<string, unknown>] => record(pair[1]));
|
|
234
246
|
nodes.sort((a, b) => timestamp(record(a[1].message) ? a[1].message.create_time : undefined, c.date)
|
|
235
247
|
.localeCompare(timestamp(record(b[1].message) ? b[1].message.create_time : undefined, c.date)));
|
|
236
|
-
const ordered: typeof nodes = [], visited = new Set<string>()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
const ordered: typeof nodes = [], visited = new Set<string>();
|
|
249
|
+
for (const first of nodes) {
|
|
250
|
+
const chain: typeof nodes = [], inChain = new Set<string>();
|
|
251
|
+
let link: (typeof nodes)[number] | undefined = first;
|
|
252
|
+
while (link && !visited.has(link[0])) {
|
|
253
|
+
const [key, node]: (typeof nodes)[number] = link;
|
|
254
|
+
if (inChain.has(key)) throw new Error(`${c.title}: cycle in ChatGPT conversation mapping.`);
|
|
255
|
+
inChain.add(key); chain.push(link);
|
|
256
|
+
const parent = typeof node.parent === 'string' ? mapping[node.parent] : undefined;
|
|
257
|
+
link = typeof node.parent === 'string' && record(parent) ? [node.parent, parent] : undefined;
|
|
258
|
+
}
|
|
259
|
+
for (const done of chain.reverse()) { visited.add(done[0]); ordered.push(done); }
|
|
260
|
+
}
|
|
246
261
|
const selected = new Set<string>();
|
|
247
262
|
let cursor = string(c.exported?.current_node);
|
|
248
263
|
const hasSelectedBranch = !!cursor;
|
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
|
|
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
|
-
|
|
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}
|
|
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();
|
|
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.
|
|
318
|
-
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1
|
|
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
|
|
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
|
|
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/inspector.ts
CHANGED
|
@@ -2,11 +2,23 @@ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithA
|
|
|
2
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
4
|
import { isActiveRun } from './runs.ts';
|
|
5
|
-
import
|
|
5
|
+
import type { Usage } from '@earendil-works/pi-ai';
|
|
6
|
+
import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
|
|
6
7
|
|
|
7
8
|
export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
|
|
8
9
|
export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
9
10
|
export const count = (n: number) => n.toLocaleString('en-US');
|
|
11
|
+
const units = ['', 'k', 'M', 'B'];
|
|
12
|
+
/** 950, 12.3k, 164k, 2.4M: enough precision to compare rows at a glance. A value that rounds to 1000 moves up a unit. */
|
|
13
|
+
export const short = (n: number) => {
|
|
14
|
+
let unit = 0, value = n;
|
|
15
|
+
const round = (v: number) => v >= 100 || !unit ? Math.round(v) : Number(v.toFixed(1));
|
|
16
|
+
while (unit < units.length - 1 && round(value) >= 1000) { value /= 1000; unit++; }
|
|
17
|
+
return `${round(value)}${units[unit]}`;
|
|
18
|
+
};
|
|
19
|
+
const dollars = (n: number) => `$${n.toFixed(2)}`;
|
|
20
|
+
const cached = (u: Usage) => { const input = u.input + u.cacheRead + u.cacheWrite; return `${input ? Math.round(100 * u.cacheRead / input) : 0}%`; };
|
|
21
|
+
const roleNames: Record<UsageRole, string> = { main: 'main', subagent: 'subagents', compactor: 'compactor', import: 'import' };
|
|
10
22
|
export const elapsed = (ms: number) => ms < 60_000 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.floor(ms / 1000) % 60}s`;
|
|
11
23
|
/** Shortens plain text with an ellipsis, preferring a word boundary. */
|
|
12
24
|
export function fit(text: string, width: number) {
|
|
@@ -23,7 +35,7 @@ const spread = (left: string, right: string, width: number) => {
|
|
|
23
35
|
export type InspectorPage = 'agents' | 'usage';
|
|
24
36
|
/** Closing the panel either picks the subagent model or opens one agent's conversation. */
|
|
25
37
|
export type InspectorAction = 'model' | { open: string };
|
|
26
|
-
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'border';
|
|
38
|
+
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'border';
|
|
27
39
|
interface Options {
|
|
28
40
|
profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
|
|
29
41
|
rows: () => number; redraw: () => void; done: (action?: InspectorAction) => void;
|
|
@@ -96,24 +108,34 @@ export class Inspector implements Component, Focusable {
|
|
|
96
108
|
this.scroll = Math.max(0, Math.min(this.scroll, this.lineCount - this.height));
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
/** Period tabs, one headline, then an aligned table with a row per role and model. */
|
|
112
|
+
private usageLines(width: number) {
|
|
113
|
+
const { usage, session, context, color } = this.options;
|
|
101
114
|
const entries = usage.select(this.range, session);
|
|
102
115
|
const { total, groups } = summarizeUsage(entries);
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'
|
|
109
|
-
|
|
116
|
+
// No-break spaces keep a label like "Last 7 days" on one line when the tabs wrap.
|
|
117
|
+
const tabs = ranges.map(r => r.replaceAll(' ', '\u00a0')).map((r, i) => ranges[i] === this.range ? color('accent', `[${r}]`) : color('dim', r)).join(' ');
|
|
118
|
+
const lines = [...wrapTextWithAnsi(tabs, width), ''];
|
|
119
|
+
if (!entries.length) lines.push(color('muted', 'No usage in this period.'));
|
|
120
|
+
else {
|
|
121
|
+
lines.push(`${color('accent', dollars(total.cost.total))} estimated · ${short(total.totalTokens)} tokens · ${cached(total)} cached`, '');
|
|
122
|
+
const order = ['main', 'subagent', 'compactor', 'import'] as const;
|
|
123
|
+
const sorted = groups.sort((a, b) => order.indexOf(a.role) - order.indexOf(b.role) || b.usage.cost.total - a.usage.cost.total);
|
|
124
|
+
const rows = sorted.map((g, i) => [i && sorted[i - 1].role === g.role ? '' : roleNames[g.role], oneLine(g.model), dollars(g.usage.cost.total),
|
|
125
|
+
`${total.cost.total ? Math.round(100 * g.usage.cost.total / total.cost.total) : 0}%`, short(g.usage.output), cached(g.usage)]);
|
|
126
|
+
const header = ['', 'model', 'cost', 'share', 'out', 'cached'];
|
|
127
|
+
const widths = header.map((h, c) => Math.max(h.length, ...rows.map(r => r[c].length)));
|
|
128
|
+
// The model column goes first when the screen is too narrow for every column.
|
|
129
|
+
const columns = widths.reduce((sum, w) => sum + w + 2, 0) - 2 <= width ? [0, 1, 2, 3, 4, 5] : [0, 2, 3, 4, 5];
|
|
130
|
+
const line = (cells: string[]) => columns.map(c => c < 2 ? cells[c].padEnd(widths[c]) : cells[c].padStart(widths[c])).join(' ').trimEnd();
|
|
131
|
+
lines.push(color('dim', line(header)), ...rows.map(r => {
|
|
132
|
+
const text = line(r); return color('muted', text.slice(0, widths[0])) + text.slice(widths[0]);
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
110
135
|
const current = context();
|
|
111
|
-
lines.push('',
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
'Main tracking starts with this version; the resumed parent session and saved children are backfilled.',
|
|
115
|
-
'Older compactor/child records without a parent session appear only in date-based totals.', ...usage.warnings);
|
|
116
|
-
return lines.join('\n');
|
|
136
|
+
if (current != null) lines.push('', `${color('muted', 'Context now')} ${short(current)} tokens`);
|
|
137
|
+
lines.push('', color('dim', 'Estimated at API prices, not your subscription bill.'), ...usage.warnings.map(w => color('warning', w)));
|
|
138
|
+
return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
|
|
117
139
|
}
|
|
118
140
|
render(width: number): string[] {
|
|
119
141
|
const { color, children, profile } = this.options;
|
|
@@ -121,11 +143,11 @@ export class Inspector implements Component, Focusable {
|
|
|
121
143
|
let title = `OptChat · ${profile} · ${this.page === 'usage' ? 'Usage' : 'Agents'}`;
|
|
122
144
|
let info: string, body: string[], hint: string;
|
|
123
145
|
if (this.page === 'usage') {
|
|
124
|
-
const lines =
|
|
146
|
+
const lines = this.usageLines(inner); this.lineCount = lines.length;
|
|
125
147
|
this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
|
|
126
148
|
body = lines.slice(this.scroll, this.scroll + this.height);
|
|
127
149
|
info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}`;
|
|
128
|
-
hint = '←→ period ·
|
|
150
|
+
hint = '←→ period · ↑↓ scroll · Tab agents · Esc close';
|
|
129
151
|
} else {
|
|
130
152
|
const list = children.history.list();
|
|
131
153
|
if (!this.selected) this.selected = list[0]?.id;
|
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
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 &&
|
|
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
|
|
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
|
-
&&
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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);
|