@tshu-w/pi-agents 0.1.0 → 0.2.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 +27 -20
- package/bin/daemon.mjs +33 -15
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +52 -38
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +10 -6
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/daemon/sessions.mjs +7 -0
- package/src/host/client.mjs +25 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +78 -31
package/src/workbench.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { readFile } from 'node:fs/promises';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { styleText } from 'node:util';
|
|
9
10
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
-
import { attach, listHosts, startHost } from './host/client.mjs';
|
|
11
|
+
import { alive, attach, listHosts, settledHosts, startHost } from './host/client.mjs';
|
|
11
12
|
import { detachKey, detachMatcher } from './host/keys.mjs';
|
|
12
13
|
import { piPackage } from './pi.mjs';
|
|
13
14
|
import { ensureDaemon, sendViaDaemon } from './daemon/client.mjs';
|
|
@@ -66,6 +67,8 @@ function age(ms) {
|
|
|
66
67
|
|
|
67
68
|
const time = session => Math.max(session.finishedAt ?? 0, Date.parse(session.updatedAt ?? '') || 0);
|
|
68
69
|
const label = session => session.name || session.title || session.id.slice(0, 8);
|
|
70
|
+
// Notices quote the label, since a title can read as part of the notice.
|
|
71
|
+
const named = session => `"${label(session)}"`;
|
|
69
72
|
const COMMANDS = [
|
|
70
73
|
{ value: '/resume', label: 'resume', description: 'Open a past Session' },
|
|
71
74
|
{ value: '/quit', label: 'quit', description: 'Exit the workbench' },
|
|
@@ -73,9 +76,9 @@ const COMMANDS = [
|
|
|
73
76
|
// Pi's working spinner.
|
|
74
77
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
75
78
|
const GROUPS = [
|
|
76
|
-
['attention', 'Needs
|
|
77
|
-
['working', 'Working', 'Sessions
|
|
78
|
-
['completed', 'Completed', 'Sessions
|
|
79
|
+
['attention', 'Needs attention', 'Sessions with a question for you or a reply you have not read'],
|
|
80
|
+
['working', 'Working', 'Sessions still working; they keep running when you close the terminal'],
|
|
81
|
+
['completed', 'Completed', 'Sessions with nothing new for you'],
|
|
79
82
|
];
|
|
80
83
|
|
|
81
84
|
function stateOf(session) {
|
|
@@ -110,6 +113,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
110
113
|
let sessions = [], stream, connected = false, quitting = false;
|
|
111
114
|
// `picked` is the Session last chosen with `@` and its mention; a message starting with the mention goes to it.
|
|
112
115
|
let selectedKey, top = 0, notice, modal, pane, picked, lastCtrlC = 0, finish;
|
|
116
|
+
// Messages in flight by Session ID; each settles once the daemon has delivered it, waking the Session if needed.
|
|
117
|
+
const sending = new Map();
|
|
113
118
|
|
|
114
119
|
/** How `@` refers to a Session: its name when that is one word, otherwise the shortest unique ID prefix. */
|
|
115
120
|
function mention(session) {
|
|
@@ -199,8 +204,9 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
199
204
|
// While the preview is open, the keys are its own: Enter may send a message, so `app.tools.expand` attaches.
|
|
200
205
|
const keys = pane ? [
|
|
201
206
|
hints.keyHint('app.tools.expand', 'attach'),
|
|
202
|
-
hints.rawKeyHint(
|
|
203
|
-
|
|
207
|
+
hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
|
|
208
|
+
.map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
|
|
209
|
+
hints.rawKeyHint(`${detach}/esc`, 'close'),
|
|
204
210
|
].join(theme.fg('muted', ' · ')) : [
|
|
205
211
|
hints.rawKeyHint('enter', 'attach'),
|
|
206
212
|
hints.rawKeyHint(detach, 'quit'),
|
|
@@ -273,8 +279,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
273
279
|
handleInput(data) {
|
|
274
280
|
// Holding only the preview's mention, the input counts as empty.
|
|
275
281
|
const empty = ['', pane?.mention].includes(editor.getText());
|
|
276
|
-
// As in a Session, the detach key leaves and everything keeps running.
|
|
277
|
-
if (isDetach(data)) return void quit();
|
|
282
|
+
// As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
|
|
283
|
+
if (isDetach(data)) return void (pane ? pane.close() : quit());
|
|
278
284
|
if (pane?.handleInput(data, empty)) return;
|
|
279
285
|
if (T.matchesKey(data, 'ctrl+c')) {
|
|
280
286
|
if (!empty) return editor.setText(pane?.mention ?? '');
|
|
@@ -283,7 +289,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
283
289
|
return say('Press Ctrl+C again to quit');
|
|
284
290
|
}
|
|
285
291
|
if (T.matchesKey(data, 'ctrl+x')) return interrupt();
|
|
286
|
-
if (empty && keybindings.matches(data, 'app.session.delete')) return remove(selected()?.session);
|
|
292
|
+
if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
|
|
287
293
|
// The preview's mention stays: an edit that would remove or change it is undone.
|
|
288
294
|
if (pane) {
|
|
289
295
|
const before = editor.getText();
|
|
@@ -363,12 +369,15 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
363
369
|
async function openSession(session) {
|
|
364
370
|
stream?.send({ type: 'show', id: session.id });
|
|
365
371
|
try {
|
|
366
|
-
|
|
372
|
+
// A message just sent may be waking the Session in a host.
|
|
373
|
+
await sending.get(session.id);
|
|
374
|
+
const running = host => host.session?.id === session.id;
|
|
375
|
+
const hosted = (await settledHosts(paths, running)).find(running);
|
|
367
376
|
if (hosted) return await attachTo(hosted.socket);
|
|
368
377
|
if (session.running || session.status === 'unknown') {
|
|
369
|
-
return say(`${
|
|
378
|
+
return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
|
|
370
379
|
}
|
|
371
|
-
say(`Starting ${
|
|
380
|
+
say(`Starting ${named(session)}…`);
|
|
372
381
|
const socket = await startHost(paths, {
|
|
373
382
|
pi: executable, args: ['--session', session.sessionFile], cwd: session.cwd,
|
|
374
383
|
cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
|
|
@@ -385,20 +394,32 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
385
394
|
const session = selected()?.session;
|
|
386
395
|
if (!['working', 'blocked'].includes(session?.status) || !stream) return say('The selected Session is not working.', 'warning');
|
|
387
396
|
stream.send({ type: 'interrupt', id: session.id });
|
|
388
|
-
say(`Stopping ${
|
|
397
|
+
say(`Stopping ${named(session)}…`);
|
|
389
398
|
}
|
|
390
399
|
|
|
391
|
-
/**
|
|
392
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Removes a Session from the list; `/resume` brings it back. An idle Session in a host no terminal
|
|
402
|
+
* is attached to quits first, as Pi does on SIGTERM; any other running Session always stays listed.
|
|
403
|
+
*/
|
|
404
|
+
async function remove(session) {
|
|
393
405
|
if (!session || !stream) return;
|
|
394
|
-
if (session.running
|
|
406
|
+
if (session.running) {
|
|
407
|
+
const host = session.status === 'idle' && (await listHosts(paths)).find(host => host.session?.id === session.id);
|
|
408
|
+
if (!host || host.attached) return say(`Quit ${named(session)} before removing it.`, 'warning');
|
|
409
|
+
say(`Quitting ${named(session)}…`);
|
|
410
|
+
process.kill(host.pid, 'SIGTERM');
|
|
411
|
+
for (let wait = 0; alive(host.pid); wait += 100) {
|
|
412
|
+
if (wait >= 5000) return say(`${named(session)} did not quit; attach it to see why.`, 'warning');
|
|
413
|
+
await delay(100);
|
|
414
|
+
}
|
|
415
|
+
} else if (session.status !== 'idle') return say(`Quit ${named(session)} before removing it.`, 'warning');
|
|
395
416
|
stream.send({ type: 'hide', id: session.id });
|
|
396
417
|
if (pane?.session.id === session.id) pane.close();
|
|
397
418
|
const choices = selectable();
|
|
398
419
|
const index = choices.findIndex(row => row.key === session.id);
|
|
399
420
|
selectedKey = (choices[index + 1] ?? choices[index - 1])?.key;
|
|
400
421
|
sessions = sessions.filter(other => other.id !== session.id);
|
|
401
|
-
say(`Removed ${
|
|
422
|
+
say(`Removed ${named(session)}; /resume brings it back.`);
|
|
402
423
|
}
|
|
403
424
|
|
|
404
425
|
async function preview(session) {
|
|
@@ -408,19 +429,25 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
408
429
|
catch (error) { return say(error.message, 'error'); }
|
|
409
430
|
let body = conversation(messages);
|
|
410
431
|
const border = themes.getEditorTheme().borderColor;
|
|
411
|
-
let scroll = 0, page = 1, timer;
|
|
432
|
+
let scroll = 0, page = 1, timer, scrolled, hideBar;
|
|
433
|
+
// A turn the preview shows counts as seen.
|
|
434
|
+
const see = () => stream?.send({ type: 'see', id: session.id });
|
|
435
|
+
see();
|
|
412
436
|
// Pi appends each finished message to the file, so the preview follows the Session as it works.
|
|
413
437
|
const watcher = watch(session.sessionFile, () => {
|
|
414
438
|
clearTimeout(timer);
|
|
415
439
|
timer = setTimeout(async () => {
|
|
416
440
|
try { body = conversation(await branchMessages(session.sessionFile)); }
|
|
417
441
|
catch (error) { return say(error.message, 'error'); }
|
|
442
|
+
see();
|
|
418
443
|
ui.requestRender();
|
|
419
444
|
}, 100);
|
|
420
445
|
});
|
|
421
446
|
const close = () => {
|
|
422
447
|
watcher.close();
|
|
448
|
+
see();
|
|
423
449
|
clearTimeout(timer);
|
|
450
|
+
clearTimeout(hideBar);
|
|
424
451
|
// A draft stays, still addressed to the Session.
|
|
425
452
|
if (editor.getText() === pane?.mention) editor.setText('');
|
|
426
453
|
pane = undefined;
|
|
@@ -435,14 +462,23 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
435
462
|
invalidate() { body.invalidate(); },
|
|
436
463
|
/** A short conversation takes only its own height; a longer one takes `max` lines and scrolls. */
|
|
437
464
|
render(width, max) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
const
|
|
465
|
+
// The selected row and the input's mention name the Session, so the preview needs no title. A blank line
|
|
466
|
+
// sets it apart from the list.
|
|
467
|
+
const top = ['', border('─'.repeat(width))];
|
|
468
|
+
const bar = width > 1 ? settings.getFullscreenScrollbar() : 'hidden';
|
|
469
|
+
// The conversation starts right below the border.
|
|
470
|
+
const lines = body.render(bar === 'always' ? width - 1 : width);
|
|
471
|
+
while (lines.length && !T.stripTerminalSequences(lines[0]).trim()) lines.shift();
|
|
442
472
|
page = Math.max(1, max - 2);
|
|
443
473
|
scroll = Math.min(scroll, Math.max(0, lines.length - page));
|
|
444
|
-
const end = lines.length - scroll;
|
|
445
|
-
|
|
474
|
+
const end = lines.length - scroll, start = Math.max(0, end - page);
|
|
475
|
+
const shown = lines.slice(start, end);
|
|
476
|
+
if (bar !== 'always' && !(bar === 'auto' && scrolled && lines.length > page)) return [...top, ...shown];
|
|
477
|
+
// The scrollbar of Pi's fullscreen transcript, in the last column.
|
|
478
|
+
const track = shown.length, size = Math.max(Math.min(2, track), Math.min(track, Math.round(track * track / lines.length)));
|
|
479
|
+
const offset = lines.length === track ? 0 : Math.round(start / (lines.length - track) * (track - size));
|
|
480
|
+
const cell = row => row >= offset && row < offset + size ? theme.fg('scrollbarThumb', '┃') : theme.fg('scrollbarTrack', '│');
|
|
481
|
+
return [...top, ...shown.map((line, row) => `${T.truncateToWidth(line, width - 1, '', true)}${cell(row)}`)];
|
|
446
482
|
},
|
|
447
483
|
/** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
|
|
448
484
|
handleInput(data, empty) {
|
|
@@ -459,10 +495,17 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
459
495
|
close();
|
|
460
496
|
return true;
|
|
461
497
|
}
|
|
462
|
-
|
|
463
|
-
|
|
498
|
+
// The scroll keys of Pi's fullscreen mode; a step counts lines up from the end.
|
|
499
|
+
const half = Math.max(1, Math.floor(page / 2));
|
|
500
|
+
const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
|
|
501
|
+
const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
|
|
502
|
+
const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1 : action ? steps[action] : 0;
|
|
464
503
|
if (!step) return false;
|
|
465
504
|
scroll = Math.max(0, scroll + step);
|
|
505
|
+
// As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
|
|
506
|
+
scrolled = true;
|
|
507
|
+
clearTimeout(hideBar);
|
|
508
|
+
hideBar = setTimeout(() => { scrolled = false; ui.requestRender(); }, 1000);
|
|
466
509
|
ui.requestRender();
|
|
467
510
|
return true;
|
|
468
511
|
},
|
|
@@ -507,12 +550,16 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
507
550
|
}
|
|
508
551
|
|
|
509
552
|
async function send(session, body) {
|
|
510
|
-
say(`Sending to ${
|
|
553
|
+
say(`Sending to ${named(session)}…`);
|
|
511
554
|
const message = { id: randomUUID(), user: true, recipient: session.id, body };
|
|
555
|
+
const pending = sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
|
|
556
|
+
const settled = pending.then(() => {}, () => {});
|
|
557
|
+
sending.set(session.id, settled);
|
|
558
|
+
void settled.then(() => { if (sending.get(session.id) === settled) sending.delete(session.id); });
|
|
512
559
|
try {
|
|
513
|
-
const receipt = await
|
|
514
|
-
if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${
|
|
515
|
-
say(`Sent to ${
|
|
560
|
+
const receipt = await pending;
|
|
561
|
+
if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
|
|
562
|
+
say(`Sent to ${named(session)}.`);
|
|
516
563
|
} catch (error) { say(error.message, 'error'); }
|
|
517
564
|
}
|
|
518
565
|
|
|
@@ -568,7 +615,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
568
615
|
themeSync.disableAutoSync();
|
|
569
616
|
await terminal.drainInput(1000);
|
|
570
617
|
ui.stop();
|
|
571
|
-
process.stdout.write(`${MAIN_SCREEN}Sessions keep running. To reopen
|
|
618
|
+
process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
|
|
572
619
|
finish();
|
|
573
620
|
}
|
|
574
621
|
|