@tshu-w/pi-agents 0.1.1 → 0.2.1
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 +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- 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/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- 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/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
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 { alive, 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';
|
|
@@ -37,23 +38,21 @@ function textOf(content) {
|
|
|
37
38
|
return Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : '';
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** The messages on the Session's active branch since its last compaction, oldest first. */
|
|
41
|
-
async function branchMessages(file) {
|
|
42
|
-
const entries =
|
|
41
|
+
/** The messages on the Session's active branch since its last compaction, oldest first, read as Pi reads the Session. */
|
|
42
|
+
async function branchMessages(pi, file) {
|
|
43
|
+
const entries = pi.parseSessionEntries(await readFile(file, 'utf8'));
|
|
44
|
+
// Older Sessions take Pi's current form.
|
|
45
|
+
pi.migrateSessionEntries(entries);
|
|
46
|
+
const byId = new Map();
|
|
43
47
|
let leaf;
|
|
44
|
-
for (const
|
|
45
|
-
let entry;
|
|
46
|
-
try { entry = JSON.parse(line); } catch { continue; }
|
|
48
|
+
for (const entry of entries) {
|
|
47
49
|
if (typeof entry?.id !== 'string' || entry.type === 'session') continue;
|
|
48
|
-
|
|
50
|
+
byId.set(entry.id, entry);
|
|
49
51
|
leaf = entry;
|
|
50
52
|
}
|
|
51
|
-
const
|
|
52
|
-
for (let entry = leaf; entry && entry.type !== 'compaction'; entry =
|
|
53
|
-
|
|
54
|
-
else if (entry.type === 'custom_message') messages.push({ role: 'custom', ...entry });
|
|
55
|
-
}
|
|
56
|
-
return messages.reverse();
|
|
53
|
+
const branch = [];
|
|
54
|
+
for (let entry = leaf; entry && entry.type !== 'compaction'; entry = byId.get(entry.parentId)) branch.push(entry);
|
|
55
|
+
return branch.reverse().flatMap(entry => pi.sessionEntryToContextMessages(entry));
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
function age(ms) {
|
|
@@ -75,9 +74,9 @@ const COMMANDS = [
|
|
|
75
74
|
// Pi's working spinner.
|
|
76
75
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
77
76
|
const GROUPS = [
|
|
78
|
-
['attention', 'Needs
|
|
79
|
-
['working', 'Working', 'Sessions
|
|
80
|
-
['completed', 'Completed', 'Sessions
|
|
77
|
+
['attention', 'Needs attention', 'Sessions with a question for you or a reply you have not read'],
|
|
78
|
+
['working', 'Working', 'Sessions still working; they keep running when you close the terminal'],
|
|
79
|
+
['completed', 'Completed', 'Sessions with nothing new for you'],
|
|
81
80
|
];
|
|
82
81
|
|
|
83
82
|
function stateOf(session) {
|
|
@@ -112,6 +111,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
112
111
|
let sessions = [], stream, connected = false, quitting = false;
|
|
113
112
|
// `picked` is the Session last chosen with `@` and its mention; a message starting with the mention goes to it.
|
|
114
113
|
let selectedKey, top = 0, notice, modal, pane, picked, lastCtrlC = 0, finish;
|
|
114
|
+
// Messages in flight by Session ID; each settles once the daemon has delivered it, waking the Session if needed.
|
|
115
|
+
const sending = new Map();
|
|
115
116
|
|
|
116
117
|
/** How `@` refers to a Session: its name when that is one word, otherwise the shortest unique ID prefix. */
|
|
117
118
|
function mention(session) {
|
|
@@ -201,14 +202,15 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
201
202
|
// While the preview is open, the keys are its own: Enter may send a message, so `app.tools.expand` attaches.
|
|
202
203
|
const keys = pane ? [
|
|
203
204
|
hints.keyHint('app.tools.expand', 'attach'),
|
|
204
|
-
hints.rawKeyHint(
|
|
205
|
-
|
|
205
|
+
hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
|
|
206
|
+
.map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
|
|
207
|
+
hints.rawKeyHint(`${detach}/${hints.keyText('tui.select.cancel')}`, 'close'),
|
|
206
208
|
].join(theme.fg('muted', ' · ')) : [
|
|
207
|
-
hints.
|
|
209
|
+
hints.keyHint('tui.select.confirm', 'attach'),
|
|
208
210
|
hints.rawKeyHint(detach, 'quit'),
|
|
209
211
|
hints.rawKeyHint('space', 'preview'),
|
|
210
212
|
hints.rawKeyHint('ctrl+x', 'stop'),
|
|
211
|
-
hints.
|
|
213
|
+
hints.keyHint('app.session.delete', 'remove'),
|
|
212
214
|
hints.rawKeyHint('@', 'message'),
|
|
213
215
|
hints.rawKeyHint('/', 'commands'),
|
|
214
216
|
].join(theme.fg('muted', ' · '));
|
|
@@ -275,14 +277,14 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
275
277
|
handleInput(data) {
|
|
276
278
|
// Holding only the preview's mention, the input counts as empty.
|
|
277
279
|
const empty = ['', pane?.mention].includes(editor.getText());
|
|
278
|
-
// As in a Session, the detach key leaves and everything keeps running.
|
|
279
|
-
if (isDetach(data)) return void quit();
|
|
280
|
+
// As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
|
|
281
|
+
if (isDetach(data)) return void (pane ? pane.close() : quit());
|
|
280
282
|
if (pane?.handleInput(data, empty)) return;
|
|
281
|
-
if (
|
|
283
|
+
if (keybindings.matches(data, 'app.clear')) {
|
|
282
284
|
if (!empty) return editor.setText(pane?.mention ?? '');
|
|
283
285
|
if (Date.now() - lastCtrlC < 1000) return void quit();
|
|
284
286
|
lastCtrlC = Date.now();
|
|
285
|
-
return say(
|
|
287
|
+
return say(`Press ${hints.keyText('app.clear')} again to quit`);
|
|
286
288
|
}
|
|
287
289
|
if (T.matchesKey(data, 'ctrl+x')) return interrupt();
|
|
288
290
|
if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
|
|
@@ -293,10 +295,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
293
295
|
if (pane && !editor.getText().startsWith(pane.mention)) editor.setText(before);
|
|
294
296
|
return;
|
|
295
297
|
}
|
|
296
|
-
if (empty &&
|
|
297
|
-
if (empty &&
|
|
298
|
-
if (empty &&
|
|
299
|
-
if (empty &&
|
|
298
|
+
if (empty && keybindings.matches(data, 'tui.select.cancel')) return void quit();
|
|
299
|
+
if (empty && keybindings.matches(data, 'tui.select.up')) return move(-1);
|
|
300
|
+
if (empty && keybindings.matches(data, 'tui.select.down')) return move(1);
|
|
301
|
+
if (empty && keybindings.matches(data, 'tui.select.confirm')) return void open(selected());
|
|
300
302
|
if (empty && T.matchesKey(data, 'space')) return void preview(selected()?.session);
|
|
301
303
|
if (empty && data === '@') return pick(session => {
|
|
302
304
|
picked = { session, mention: `@${mention(session)} ` };
|
|
@@ -365,7 +367,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
365
367
|
async function openSession(session) {
|
|
366
368
|
stream?.send({ type: 'show', id: session.id });
|
|
367
369
|
try {
|
|
368
|
-
|
|
370
|
+
// A message just sent may be waking the Session in a host.
|
|
371
|
+
await sending.get(session.id);
|
|
372
|
+
const running = host => host.session?.id === session.id;
|
|
373
|
+
const hosted = (await settledHosts(paths, running)).find(running);
|
|
369
374
|
if (hosted) return await attachTo(hosted.socket);
|
|
370
375
|
if (session.running || session.status === 'unknown') {
|
|
371
376
|
return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
|
|
@@ -418,11 +423,11 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
418
423
|
async function preview(session) {
|
|
419
424
|
if (!session?.sessionFile) return;
|
|
420
425
|
let messages;
|
|
421
|
-
try { messages = await branchMessages(session.sessionFile); }
|
|
426
|
+
try { messages = await branchMessages(pi, session.sessionFile); }
|
|
422
427
|
catch (error) { return say(error.message, 'error'); }
|
|
423
428
|
let body = conversation(messages);
|
|
424
429
|
const border = themes.getEditorTheme().borderColor;
|
|
425
|
-
let scroll = 0, page = 1, timer;
|
|
430
|
+
let scroll = 0, page = 1, timer, scrolled, hideBar;
|
|
426
431
|
// A turn the preview shows counts as seen.
|
|
427
432
|
const see = () => stream?.send({ type: 'see', id: session.id });
|
|
428
433
|
see();
|
|
@@ -430,7 +435,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
430
435
|
const watcher = watch(session.sessionFile, () => {
|
|
431
436
|
clearTimeout(timer);
|
|
432
437
|
timer = setTimeout(async () => {
|
|
433
|
-
try { body = conversation(await branchMessages(session.sessionFile)); }
|
|
438
|
+
try { body = conversation(await branchMessages(pi, session.sessionFile)); }
|
|
434
439
|
catch (error) { return say(error.message, 'error'); }
|
|
435
440
|
see();
|
|
436
441
|
ui.requestRender();
|
|
@@ -440,6 +445,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
440
445
|
watcher.close();
|
|
441
446
|
see();
|
|
442
447
|
clearTimeout(timer);
|
|
448
|
+
clearTimeout(hideBar);
|
|
443
449
|
// A draft stays, still addressed to the Session.
|
|
444
450
|
if (editor.getText() === pane?.mention) editor.setText('');
|
|
445
451
|
pane = undefined;
|
|
@@ -454,34 +460,50 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
454
460
|
invalidate() { body.invalidate(); },
|
|
455
461
|
/** A short conversation takes only its own height; a longer one takes `max` lines and scrolls. */
|
|
456
462
|
render(width, max) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
const
|
|
463
|
+
// The selected row and the input's mention name the Session, so the preview needs no title. A blank line
|
|
464
|
+
// sets it apart from the list.
|
|
465
|
+
const top = ['', border('─'.repeat(width))];
|
|
466
|
+
const bar = width > 1 ? settings.getFullscreenScrollbar() : 'hidden';
|
|
467
|
+
// The conversation starts right below the border.
|
|
468
|
+
const lines = body.render(bar === 'always' ? width - 1 : width);
|
|
469
|
+
while (lines.length && !T.stripTerminalSequences(lines[0]).trim()) lines.shift();
|
|
461
470
|
page = Math.max(1, max - 2);
|
|
462
471
|
scroll = Math.min(scroll, Math.max(0, lines.length - page));
|
|
463
|
-
const end = lines.length - scroll;
|
|
464
|
-
|
|
472
|
+
const end = lines.length - scroll, start = Math.max(0, end - page);
|
|
473
|
+
const shown = lines.slice(start, end);
|
|
474
|
+
if (bar !== 'always' && !(bar === 'auto' && scrolled && lines.length > page)) return [...top, ...shown];
|
|
475
|
+
// The scrollbar of Pi's fullscreen transcript, in the last column.
|
|
476
|
+
const track = shown.length, size = Math.max(Math.min(2, track), Math.min(track, Math.round(track * track / lines.length)));
|
|
477
|
+
const offset = lines.length === track ? 0 : Math.round(start / (lines.length - track) * (track - size));
|
|
478
|
+
const cell = row => row >= offset && row < offset + size ? theme.fg('scrollbarThumb', '┃') : theme.fg('scrollbarTrack', '│');
|
|
479
|
+
return [...top, ...shown.map((line, row) => `${T.truncateToWidth(line, width - 1, '', true)}${cell(row)}`)];
|
|
465
480
|
},
|
|
466
481
|
/** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
|
|
467
482
|
handleInput(data, empty) {
|
|
468
|
-
if (keybindings.matches(data, 'app.tools.expand') || (empty &&
|
|
483
|
+
if (keybindings.matches(data, 'app.tools.expand') || (empty && keybindings.matches(data, 'tui.select.confirm'))) {
|
|
469
484
|
close();
|
|
470
485
|
void openSession(session);
|
|
471
486
|
return true;
|
|
472
487
|
}
|
|
473
|
-
if (!empty &&
|
|
488
|
+
if (!empty && keybindings.matches(data, 'tui.select.cancel')) {
|
|
474
489
|
editor.setText(pane.mention);
|
|
475
490
|
return true;
|
|
476
491
|
}
|
|
477
|
-
if (
|
|
492
|
+
if (keybindings.matches(data, 'tui.select.cancel') || (empty && (T.matchesKey(data, 'space') || keybindings.matches(data, 'app.clear')))) {
|
|
478
493
|
close();
|
|
479
494
|
return true;
|
|
480
495
|
}
|
|
481
|
-
|
|
482
|
-
|
|
496
|
+
// The scroll keys of Pi's fullscreen mode; a step counts lines up from the end.
|
|
497
|
+
const half = Math.max(1, Math.floor(page / 2));
|
|
498
|
+
const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
|
|
499
|
+
const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
|
|
500
|
+
const step = empty && keybindings.matches(data, 'tui.select.up') ? 1 : empty && keybindings.matches(data, 'tui.select.down') ? -1 : action ? steps[action] : 0;
|
|
483
501
|
if (!step) return false;
|
|
484
502
|
scroll = Math.max(0, scroll + step);
|
|
503
|
+
// As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
|
|
504
|
+
scrolled = true;
|
|
505
|
+
clearTimeout(hideBar);
|
|
506
|
+
hideBar = setTimeout(() => { scrolled = false; ui.requestRender(); }, 1000);
|
|
485
507
|
ui.requestRender();
|
|
486
508
|
return true;
|
|
487
509
|
},
|
|
@@ -490,20 +512,27 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
490
512
|
ui.requestRender(true);
|
|
491
513
|
}
|
|
492
514
|
|
|
493
|
-
/**
|
|
515
|
+
/**
|
|
516
|
+
* Messages and replies as Pi shows them; thinking is left out, and each run of tool calls, branch summary, and custom
|
|
517
|
+
* message other than the user's input folds to one line.
|
|
518
|
+
*/
|
|
494
519
|
function conversation(messages) {
|
|
495
520
|
const markdown = themes.getMarkdownTheme();
|
|
496
521
|
const view = new T.Container();
|
|
497
522
|
let tools = [];
|
|
523
|
+
// Pi's hint for folded output: "... (36 more lines, …)".
|
|
524
|
+
const summarize = summary => {
|
|
525
|
+
view.addChild(new T.Spacer(1));
|
|
526
|
+
view.addChild(new T.Text(theme.fg('muted', `... (${summary})`), 1, 0));
|
|
527
|
+
};
|
|
498
528
|
const fold = () => {
|
|
499
529
|
if (!tools.length) return;
|
|
500
|
-
|
|
501
|
-
const names = [...new Set(tools)].join(', ');
|
|
502
|
-
view.addChild(new T.Spacer(1));
|
|
503
|
-
view.addChild(new T.Text(theme.fg('muted', `... (${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${names})`), 1, 0));
|
|
530
|
+
summarize(`${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${[...new Set(tools)].join(', ')}`);
|
|
504
531
|
tools = [];
|
|
505
532
|
};
|
|
506
533
|
for (const message of messages) {
|
|
534
|
+
// As in Pi's chat, a custom message not for display is skipped.
|
|
535
|
+
if (message.role === 'custom' && !message.display) continue;
|
|
507
536
|
const user = message.role === 'user' || (message.role === 'custom' && message.customType === 'pi-agents' && message.details?.user);
|
|
508
537
|
if (user) {
|
|
509
538
|
const text = textOf(message.content).trim();
|
|
@@ -518,6 +547,13 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
518
547
|
view.addChild(new pi.AssistantMessageComponent({ ...message, content }, true, markdown));
|
|
519
548
|
}
|
|
520
549
|
tools.push(...message.content.flatMap(part => part.type === 'toolCall' ? [part.name] : []));
|
|
550
|
+
} else if (message.role === 'branchSummary') {
|
|
551
|
+
fold();
|
|
552
|
+
summarize('branch summary');
|
|
553
|
+
} else if (message.role === 'custom') {
|
|
554
|
+
const from = message.customType === 'pi-agents' ? message.details?.sender?.name ?? message.details?.sender?.id : undefined;
|
|
555
|
+
fold();
|
|
556
|
+
summarize(from ? `message from ${from}` : `${message.customType} message`);
|
|
521
557
|
}
|
|
522
558
|
}
|
|
523
559
|
fold();
|
|
@@ -528,8 +564,12 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
528
564
|
async function send(session, body) {
|
|
529
565
|
say(`Sending to ${named(session)}…`);
|
|
530
566
|
const message = { id: randomUUID(), user: true, recipient: session.id, body };
|
|
567
|
+
const pending = sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
|
|
568
|
+
const settled = pending.then(() => {}, () => {});
|
|
569
|
+
sending.set(session.id, settled);
|
|
570
|
+
void settled.then(() => { if (sending.get(session.id) === settled) sending.delete(session.id); });
|
|
531
571
|
try {
|
|
532
|
-
const receipt = await
|
|
572
|
+
const receipt = await pending;
|
|
533
573
|
if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
|
|
534
574
|
say(`Sent to ${named(session)}.`);
|
|
535
575
|
} catch (error) { say(error.message, 'error'); }
|
|
@@ -587,7 +627,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
587
627
|
themeSync.disableAutoSync();
|
|
588
628
|
await terminal.drainInput(1000);
|
|
589
629
|
ui.stop();
|
|
590
|
-
process.stdout.write(`${MAIN_SCREEN}Sessions keep running. To reopen
|
|
630
|
+
process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
|
|
591
631
|
finish();
|
|
592
632
|
}
|
|
593
633
|
|
package/docs/WORKBENCH.md
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# Pi Agents Workbench
|
|
2
|
-
|
|
3
|
-
## 1. Overview
|
|
4
|
-
|
|
5
|
-
The workbench shows the user's root Sessions in one terminal view. From it the
|
|
6
|
-
user opens a Session, reads its latest result, or sends it a message. Sessions
|
|
7
|
-
started with `pd` keep running after their terminal closes.
|
|
8
|
-
|
|
9
|
-
Three kinds of process take part:
|
|
10
|
-
|
|
11
|
-
- **Host**: one per Session. It runs Pi's full TUI in a pseudo-terminal and
|
|
12
|
-
relays it to whichever terminal is attached.
|
|
13
|
-
- **Daemon**: one per user. It tracks Sessions, delivers messages, and starts
|
|
14
|
-
hosts.
|
|
15
|
-
- **Workbench**: the daemon's terminal view, opened with `pd agents`.
|
|
16
|
-
|
|
17
|
-
Every running root Session reports its state to the daemon. The daemon starts
|
|
18
|
-
when a Session or the workbench first needs it, and exits 30 seconds after its
|
|
19
|
-
last connection closes.
|
|
20
|
-
|
|
21
|
-
Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
|
|
22
|
-
|
|
23
|
-
- `workbench.json`: seen marks and which Sessions are listed. The file is
|
|
24
|
-
versioned.
|
|
25
|
-
- `host.log`, `daemon.log`: logs.
|
|
26
|
-
|
|
27
|
-
Sockets and Session locks go in `$XDG_RUNTIME_DIR/pi-agents/`. macOS has no
|
|
28
|
-
`XDG_RUNTIME_DIR`, so there they go in `runtime/` under the state directory.
|
|
29
|
-
|
|
30
|
-
## 2. Setup
|
|
31
|
-
|
|
32
|
-
Install with npm to get the `pd` command, also named `pi-agents`, then have Pi
|
|
33
|
-
load the extension from the same copy, so the command and the extension are
|
|
34
|
-
always the same version:
|
|
35
|
-
|
|
36
|
-
npm install -g @tshu-w/pi-agents
|
|
37
|
-
pi install "$(npm root -g)/@tshu-w/pi-agents"
|
|
38
|
-
|
|
39
|
-
`pd` runs whichever `pi` is on `PATH`. Sessions started with plain `pi` also
|
|
40
|
-
load the extension, but run outside hosts and end with their terminal.
|
|
41
|
-
|
|
42
|
-
## 3. Commands
|
|
43
|
-
|
|
44
|
-
- `pd [pi options]` starts Pi interactively in a new host and attaches the
|
|
45
|
-
terminal. If the Session is already running in a host, `pd` attaches to it
|
|
46
|
-
instead.
|
|
47
|
-
- `pd agents` opens the workbench.
|
|
48
|
-
- `pd attach <session>` attaches to a Session, starting it first if it is not
|
|
49
|
-
running.
|
|
50
|
-
- Non-interactive starts (`-p`, `--mode`, piped stdin) and package commands
|
|
51
|
-
(`pd install`, `pd update`, ...) run Pi directly.
|
|
52
|
-
|
|
53
|
-
A host inherits the environment of the process that asked for it, because Pi
|
|
54
|
-
reads terminal features from the environment once, at startup.
|
|
55
|
-
|
|
56
|
-
A Session running outside a host still appears in the workbench, but can be
|
|
57
|
-
attached only after it exits.
|
|
58
|
-
|
|
59
|
-
## 4. Attach and detach
|
|
60
|
-
|
|
61
|
-
Only one terminal is attached at a time. A new terminal takes over, and the
|
|
62
|
-
previous one is told how to reattach.
|
|
63
|
-
|
|
64
|
-
On attach, the host restores the screen and the terminal modes Pi has set
|
|
65
|
-
(keyboard protocols, bracketed paste, mouse, title, program status). Images
|
|
66
|
-
come back when the host then has Pi redraw.
|
|
67
|
-
|
|
68
|
-
A host started from a terminal waits for that terminal to attach before it
|
|
69
|
-
starts Pi, so Pi detects the terminal's colors and keyboard protocol just as it
|
|
70
|
-
would without a host. A host started in the background passes the queries Pi
|
|
71
|
-
sent at startup to the first terminal that attaches, and Pi finishes detecting
|
|
72
|
-
then.
|
|
73
|
-
|
|
74
|
-
Closing the window, pressing `Ctrl+Z` (the `pi-agents.detach` keybinding), or
|
|
75
|
-
running `/detach` detaches. The Session keeps running, and the terminal prints:
|
|
76
|
-
|
|
77
|
-
Pi keeps running. To reattach, run pd attach <session>
|
|
78
|
-
|
|
79
|
-
A host disables suspend, since a suspended Pi would stay stopped; `Ctrl+Z`
|
|
80
|
-
detaches in its place.
|
|
81
|
-
|
|
82
|
-
A detached Session exits once its tree has had no live work for 5 minutes. A
|
|
83
|
-
message to a Session that is not running starts it in a host, detached.
|
|
84
|
-
|
|
85
|
-
## 5. Workbench
|
|
86
|
-
|
|
87
|
-
The list shows running Sessions, Sessions that ran without a terminal (started
|
|
88
|
-
here, woken by a message, or detached), Sessions opened here, and Sessions
|
|
89
|
-
with an unseen turn. Older Sessions are reached with `/resume`.
|
|
90
|
-
|
|
91
|
-
Each row shows a root Session's name and what it did last. The name is the
|
|
92
|
-
Session name, or else the first line of its first message. While the Session
|
|
93
|
-
works, the row shows the running tool; otherwise, the start of its last reply.
|
|
94
|
-
Running owned Agents and Programs are counted, as in `3 agents running`.
|
|
95
|
-
|
|
96
|
-
Rows are grouped by state, newest first in each group:
|
|
97
|
-
|
|
98
|
-
- **Needs input**: a dialog waiting for the user, or a finished turn the user
|
|
99
|
-
has not seen. A turn that failed after Pi's retries, or a host that exited
|
|
100
|
-
abnormally, is marked failed.
|
|
101
|
-
- **Working**: the Session or its tree has live work.
|
|
102
|
-
- **Completed**: everything else.
|
|
103
|
-
|
|
104
|
-
While a Session works, its icon spins. Otherwise the shape tells whether Pi is
|
|
105
|
-
still running (`●`) or has exited (`○`), and the color marks a finished (green)
|
|
106
|
-
or failed (red) turn, or a dialog waiting for the user (yellow). The header
|
|
107
|
-
shows Pi's logo, the size of each group, and key hints.
|
|
108
|
-
|
|
109
|
-
A turn counts as seen once it has been shown in an attached terminal or a
|
|
110
|
-
preview. Sessions
|
|
111
|
-
from before the workbench start out seen. A Session that holds its lock but is
|
|
112
|
-
not reporting to the daemon, as while its Pi exits, shows as unknown (`?`); the
|
|
113
|
-
daemon checks it again every second until that changes.
|
|
114
|
-
|
|
115
|
-
Keys:
|
|
116
|
-
|
|
117
|
-
- `Enter` attaches; detaching returns here.
|
|
118
|
-
- `Space` previews the selected Session below the list: the conversation
|
|
119
|
-
since the last compaction, with thinking left out and each run of tool
|
|
120
|
-
calls folded to one line, such as `... (6 tool calls: bash, read, edit)`.
|
|
121
|
-
The preview follows the Session as it works.
|
|
122
|
-
The input keeps `@<session>` at its start, so a message goes to the
|
|
123
|
-
previewed Session. In the preview:
|
|
124
|
-
- `Enter` sends a typed message; with none typed, it attaches.
|
|
125
|
-
- `Ctrl+O` (`app.tools.expand`) attaches.
|
|
126
|
-
- The page keys of fullscreen mode (`tui.altScreen.pageUp`/`pageDown`)
|
|
127
|
-
scroll; with no message typed, `↑`/`↓` scroll too.
|
|
128
|
-
- `Esc` clears a typed message; with none typed, it closes the preview.
|
|
129
|
-
- `Ctrl+X` stops the current turn, as `app.interrupt` does.
|
|
130
|
-
- `Ctrl+D` removes the selected Session from the list. Its file stays, and
|
|
131
|
-
`/resume` brings it back. An idle Session in a host that no terminal is
|
|
132
|
-
attached to quits first; any other running Session must quit itself.
|
|
133
|
-
- `Ctrl+Z` (`pi-agents.detach`) exits the workbench, as detaching leaves a
|
|
134
|
-
Session; every Session keeps running.
|
|
135
|
-
|
|
136
|
-
The input at the bottom:
|
|
137
|
-
|
|
138
|
-
- `@` on an empty input opens Pi's session picker. The chosen Session is
|
|
139
|
-
selected in the list, and the input starts with `@<session>`; the text after
|
|
140
|
-
it goes to that Session as a user message. After `/`, Tab completes a
|
|
141
|
-
command.
|
|
142
|
-
- `/resume` opens Pi's session picker over all directories, newest first, and
|
|
143
|
-
attaches the chosen Session. `Ctrl+D` in the picker deletes a Session file.
|
|
144
|
-
- `/quit` exits the workbench, as do `Esc` and `Ctrl+C` twice on an empty
|
|
145
|
-
input.
|
|
146
|
-
- Any other text starts a new Session in the current directory with Pi's
|
|
147
|
-
default settings, detached.
|
|
148
|
-
|
|
149
|
-
## 6. Upgrades
|
|
150
|
-
|
|
151
|
-
A host keeps running the version it started with until its Session exits.
|
|
152
|
-
When the daemon sees a newer pi-agents version, it restarts once idle, and
|
|
153
|
-
Sessions reconnect to the new daemon.
|
|
154
|
-
|
|
155
|
-
## 7. Platform
|
|
156
|
-
|
|
157
|
-
macOS and Linux. Every dependency is pure JavaScript or prebuilt: hosts use
|
|
158
|
-
`@lydell/node-pty`, and a Session lock is a file that records the process
|
|
159
|
-
holding it.
|
|
160
|
-
|
|
161
|
-
## Not in this version
|
|
162
|
-
|
|
163
|
-
Attaching several terminals to one Session, Sessions on other machines,
|
|
164
|
-
model-written summaries, and a confirmation before quitting while work is
|
|
165
|
-
running. Keeping Sessions alive across host crashes and reboots needs a
|
|
166
|
-
durable runtime, which [Pi Durable](https://earendil.com/posts/pi-durable/)
|
|
167
|
-
explores.
|