@tshu-w/pi-agents 0.1.0 → 0.1.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/bin/daemon.mjs CHANGED
@@ -152,7 +152,7 @@ function openWorkbench(channel) {
152
152
  channel.onMessage(message => {
153
153
  if (typeof message?.id !== 'string') return;
154
154
  if (message.type === 'interrupt') channels.get(message.id)?.send({ type: 'interrupt', id: message.id });
155
- else if (message.type === 'show' || message.type === 'hide') {
155
+ else if (message.type === 'show' || message.type === 'hide' || message.type === 'see') {
156
156
  sessions[message.type](message.id);
157
157
  changed();
158
158
  }
package/docs/WORKBENCH.md CHANGED
@@ -106,7 +106,8 @@ still running (`●`) or has exited (`○`), and the color marks a finished (gre
106
106
  or failed (red) turn, or a dialog waiting for the user (yellow). The header
107
107
  shows Pi's logo, the size of each group, and key hints.
108
108
 
109
- A turn counts as seen once it has been shown in an attached terminal. Sessions
109
+ A turn counts as seen once it has been shown in an attached terminal or a
110
+ preview. Sessions
110
111
  from before the workbench start out seen. A Session that holds its lock but is
111
112
  not reporting to the daemon, as while its Pi exits, shows as unknown (`?`); the
112
113
  daemon checks it again every second until that changes.
@@ -127,7 +128,8 @@ Keys:
127
128
  - `Esc` clears a typed message; with none typed, it closes the preview.
128
129
  - `Ctrl+X` stops the current turn, as `app.interrupt` does.
129
130
  - `Ctrl+D` removes the selected Session from the list. Its file stays, and
130
- `/resume` brings it back. A running Session must quit first.
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.
131
133
  - `Ctrl+Z` (`pi-agents.detach`) exits the workbench, as detaching leaves a
132
134
  Session; every Session keeps running.
133
135
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tshu-w/pi-agents",
3
- "version": "0.1.0",
4
- "description": "Multi-agent runtime for Pi: async agents, programmatic delegation, and inter-agent messaging",
3
+ "version": "0.1.1",
4
+ "description": "Multi-agent runtime for Pi: async agents, programmatic delegation, inter-agent messaging, and a workbench for your Sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -21,8 +21,10 @@
21
21
  "keywords": [
22
22
  "pi-package",
23
23
  "pi-extension",
24
- "agents",
25
- "multi-agent"
24
+ "pi-coding-agent",
25
+ "pi",
26
+ "multi-agent",
27
+ "rlm"
26
28
  ],
27
29
  "pi": {
28
30
  "extensions": [
@@ -30,7 +32,7 @@
30
32
  ]
31
33
  },
32
34
  "scripts": {
33
- "test": "node --test --test-timeout=30000 tests/",
35
+ "test": "node --test --test-timeout=30000 \"tests/*.test.mjs\"",
34
36
  "typecheck": "tsc -p tsconfig.json"
35
37
  },
36
38
  "dependencies": {
@@ -87,6 +87,13 @@ export async function createSessions(paths, sessionDir) {
87
87
  (marks[id] ??= {}).listed = true;
88
88
  save();
89
89
  },
90
+ /** Marks a Session's last turn seen, as when the user previews it. */
91
+ see(id) {
92
+ const mark = marks[id];
93
+ if (!mark?.last || (mark.seen ?? 0) >= mark.last.at) return;
94
+ mark.seen = mark.last.at;
95
+ save();
96
+ },
90
97
  /** Removes a Session from the workbench list and clears its unseen turn; its file stays. */
91
98
  hide(id) {
92
99
  const mark = marks[id];
@@ -27,7 +27,7 @@ function hostStatus(path) {
27
27
  });
28
28
  }
29
29
 
30
- function alive(pid) {
30
+ export function alive(pid) {
31
31
  try { process.kill(pid, 0); return true; }
32
32
  catch (error) { return error.code !== 'ESRCH'; }
33
33
  }
package/src/workbench.mjs CHANGED
@@ -7,7 +7,7 @@ import { createRequire } from 'node:module';
7
7
  import { join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
- import { attach, listHosts, startHost } from './host/client.mjs';
10
+ import { alive, attach, listHosts, startHost } from './host/client.mjs';
11
11
  import { detachKey, detachMatcher } from './host/keys.mjs';
12
12
  import { piPackage } from './pi.mjs';
13
13
  import { ensureDaemon, sendViaDaemon } from './daemon/client.mjs';
@@ -66,6 +66,8 @@ function age(ms) {
66
66
 
67
67
  const time = session => Math.max(session.finishedAt ?? 0, Date.parse(session.updatedAt ?? '') || 0);
68
68
  const label = session => session.name || session.title || session.id.slice(0, 8);
69
+ // Notices quote the label, since a title can read as part of the notice.
70
+ const named = session => `"${label(session)}"`;
69
71
  const COMMANDS = [
70
72
  { value: '/resume', label: 'resume', description: 'Open a past Session' },
71
73
  { value: '/quit', label: 'quit', description: 'Exit the workbench' },
@@ -283,7 +285,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
283
285
  return say('Press Ctrl+C again to quit');
284
286
  }
285
287
  if (T.matchesKey(data, 'ctrl+x')) return interrupt();
286
- if (empty && keybindings.matches(data, 'app.session.delete')) return remove(selected()?.session);
288
+ if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
287
289
  // The preview's mention stays: an edit that would remove or change it is undone.
288
290
  if (pane) {
289
291
  const before = editor.getText();
@@ -366,9 +368,9 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
366
368
  const hosted = (await listHosts(paths)).find(host => host.session?.id === session.id);
367
369
  if (hosted) return await attachTo(hosted.socket);
368
370
  if (session.running || session.status === 'unknown') {
369
- return say(`${label(session)} runs outside a host; attach it after it exits.`, 'warning');
371
+ return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
370
372
  }
371
- say(`Starting ${label(session)}…`);
373
+ say(`Starting ${named(session)}…`);
372
374
  const socket = await startHost(paths, {
373
375
  pi: executable, args: ['--session', session.sessionFile], cwd: session.cwd,
374
376
  cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
@@ -385,20 +387,32 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
385
387
  const session = selected()?.session;
386
388
  if (!['working', 'blocked'].includes(session?.status) || !stream) return say('The selected Session is not working.', 'warning');
387
389
  stream.send({ type: 'interrupt', id: session.id });
388
- say(`Stopping ${label(session)}…`);
390
+ say(`Stopping ${named(session)}…`);
389
391
  }
390
392
 
391
- /** Removes a Session from the list; `/resume` brings it back. A running Session always stays listed. */
392
- function remove(session) {
393
+ /**
394
+ * Removes a Session from the list; `/resume` brings it back. An idle Session in a host no terminal
395
+ * is attached to quits first, as Pi does on SIGTERM; any other running Session always stays listed.
396
+ */
397
+ async function remove(session) {
393
398
  if (!session || !stream) return;
394
- if (session.running || session.status !== 'idle') return say(`Quit ${label(session)} before removing it.`, 'warning');
399
+ if (session.running) {
400
+ const host = session.status === 'idle' && (await listHosts(paths)).find(host => host.session?.id === session.id);
401
+ if (!host || host.attached) return say(`Quit ${named(session)} before removing it.`, 'warning');
402
+ say(`Quitting ${named(session)}…`);
403
+ process.kill(host.pid, 'SIGTERM');
404
+ for (let wait = 0; alive(host.pid); wait += 100) {
405
+ if (wait >= 5000) return say(`${named(session)} did not quit; attach it to see why.`, 'warning');
406
+ await delay(100);
407
+ }
408
+ } else if (session.status !== 'idle') return say(`Quit ${named(session)} before removing it.`, 'warning');
395
409
  stream.send({ type: 'hide', id: session.id });
396
410
  if (pane?.session.id === session.id) pane.close();
397
411
  const choices = selectable();
398
412
  const index = choices.findIndex(row => row.key === session.id);
399
413
  selectedKey = (choices[index + 1] ?? choices[index - 1])?.key;
400
414
  sessions = sessions.filter(other => other.id !== session.id);
401
- say(`Removed ${label(session)}; /resume brings it back.`);
415
+ say(`Removed ${named(session)}; /resume brings it back.`);
402
416
  }
403
417
 
404
418
  async function preview(session) {
@@ -409,17 +423,22 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
409
423
  let body = conversation(messages);
410
424
  const border = themes.getEditorTheme().borderColor;
411
425
  let scroll = 0, page = 1, timer;
426
+ // A turn the preview shows counts as seen.
427
+ const see = () => stream?.send({ type: 'see', id: session.id });
428
+ see();
412
429
  // Pi appends each finished message to the file, so the preview follows the Session as it works.
413
430
  const watcher = watch(session.sessionFile, () => {
414
431
  clearTimeout(timer);
415
432
  timer = setTimeout(async () => {
416
433
  try { body = conversation(await branchMessages(session.sessionFile)); }
417
434
  catch (error) { return say(error.message, 'error'); }
435
+ see();
418
436
  ui.requestRender();
419
437
  }, 100);
420
438
  });
421
439
  const close = () => {
422
440
  watcher.close();
441
+ see();
423
442
  clearTimeout(timer);
424
443
  // A draft stays, still addressed to the Session.
425
444
  if (editor.getText() === pane?.mention) editor.setText('');
@@ -507,12 +526,12 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
507
526
  }
508
527
 
509
528
  async function send(session, body) {
510
- say(`Sending to ${label(session)}…`);
529
+ say(`Sending to ${named(session)}…`);
511
530
  const message = { id: randomUUID(), user: true, recipient: session.id, body };
512
531
  try {
513
532
  const receipt = await sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
514
- if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${label(session)}; it may have received the message.`);
515
- say(`Sent to ${label(session)}.`);
533
+ if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
534
+ say(`Sent to ${named(session)}.`);
516
535
  } catch (error) { say(error.message, 'error'); }
517
536
  }
518
537