@thurstonsand/pi-agent-switchboard 0.6.4 → 0.7.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.
Files changed (2) hide show
  1. package/index.js +320 -84
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // packages/lines/pi/src/index.ts
2
2
  import { spawn } from "node:child_process";
3
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
4
- import { dirname as dirname3, join as join2 } from "node:path";
3
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4
+ import { dirname as dirname4, join as join3 } from "node:path";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
 
7
7
  // packages/shared/src/index.ts
@@ -73,8 +73,13 @@ class Db {
73
73
  }
74
74
 
75
75
  // packages/shared/src/index.ts
76
- var SCHEMA_VERSION = 5;
76
+ var SCHEMA_VERSION = 6;
77
+ var VIEWS = ["agent", "editor", "split"];
77
78
  var HARNESSES = ["pi", "claude", "codex"];
79
+ var SNOOZE_PRESETS = ["one_hour", "one_day", "monday", "next_message"];
80
+ function stateDir() {
81
+ return join(process.env.XDG_STATE_HOME || join(homedir(), ".local/state"), "agent-switchboard");
82
+ }
78
83
  function configDir() {
79
84
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "agent-switchboard");
80
85
  }
@@ -88,6 +93,23 @@ function operatorDir() {
88
93
  return physicalOperatorDir;
89
94
  }
90
95
  var physicalOperatorDir;
96
+ function shortlistPath() {
97
+ return join(stateDir(), "shortlist.txt");
98
+ }
99
+ function launchGuide() {
100
+ try {
101
+ return readFileSync(shortlistPath(), "utf8");
102
+ } catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return "";
105
+ throw error;
106
+ }
107
+ }
108
+ function describeTool(name, description, guide) {
109
+ return name === "session_launch" && guide !== "" ? `${description}
110
+
111
+ ${guide}` : description;
112
+ }
91
113
  function bootId() {
92
114
  if (process.platform === "darwin")
93
115
  return execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8" }).trim();
@@ -118,39 +140,8 @@ function branch(cwd) {
118
140
  }
119
141
 
120
142
  // packages/shared/src/mailbox.ts
121
- var HARNESS_NAMES = { pi: "pi", claude: "Claude Code", codex: "Codex" };
122
- function harnessName(harness) {
123
- return HARNESS_NAMES[harness];
124
- }
125
- function pendingFor(db, targetId) {
126
- return db.all(`SELECT id, kind, source_id, target_id, body, request_response, created_at FROM messages
127
- WHERE target_id = ? AND delivered_at IS NULL AND cancelled_at IS NULL ORDER BY created_at, rowid`, targetId).map((row) => ({
128
- id: row.id,
129
- kind: row.kind,
130
- sourceId: row.source_id,
131
- targetId: row.target_id,
132
- body: row.body,
133
- requestResponse: row.request_response === 1,
134
- createdAt: row.created_at
135
- }));
136
- }
137
- function markDelivered(db, id) {
138
- db.run("UPDATE messages SET delivered_at = coalesce(delivered_at, ?) WHERE id = ?", Date.now(), id);
139
- }
140
- function sender(db, sourceId) {
141
- const row = db.get("SELECT title, harness FROM sessions WHERE session_id = ?", sourceId);
142
- return { title: row?.title ?? sourceId, harness: row?.harness ?? null };
143
- }
144
- function frame(db, m) {
145
- const from = sender(db, m.sourceId);
146
- const harness = from.harness ? `, ${harnessName(from.harness)}` : "";
147
- const reply = m.requestResponse ? `
148
-
149
- Response requested: reply with session_send_message to session ${m.sourceId}.` : "";
150
- return `Message from session "${from.title}" (session: ${m.sourceId}${harness}):
151
-
152
- ${m.body}${reply}`;
153
- }
143
+ import { mkdirSync as mkdirSync2, rmSync, watch, writeFileSync } from "node:fs";
144
+ import { dirname as dirname3, join as join2 } from "node:path";
154
145
 
155
146
  // packages/shared/src/record.ts
156
147
  function recordTx(db, body) {
@@ -187,10 +178,15 @@ function startHosting(db, h) {
187
178
  function startTurn(db, t) {
188
179
  recordTx(db, () => {
189
180
  const now = Date.now();
181
+ const first = !db.get("SELECT 1 FROM sessions WHERE session_id = ?", t.sessionId);
190
182
  db.run(`INSERT INTO sessions (session_id, harness, parent_id, cwd, project_root, transcript, title, branch, phase, created_at, last_prompt_at)
191
183
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'working', ?, ?)
192
184
  ON CONFLICT (session_id) DO UPDATE SET phase = 'working',
193
185
  last_prompt_at = CASE WHEN sessions.phase = 'idle' THEN excluded.last_prompt_at ELSE sessions.last_prompt_at END`, t.sessionId, t.harness, t.parentId, t.cwd, projectRoot(t.cwd), t.transcript, t.title, branch(t.cwd), now, now);
186
+ if (first && t.parentId)
187
+ db.run(`INSERT INTO marks (session_id, group_name)
188
+ SELECT ?, m.group_name FROM sessions p JOIN marks m USING (session_id)
189
+ WHERE p.session_id = ? AND p.project_root = ? AND m.group_name IS NOT NULL`, t.sessionId, t.parentId, projectRoot(t.cwd));
194
190
  });
195
191
  }
196
192
  function setPhase(db, sessionId, phase) {
@@ -198,13 +194,33 @@ function setPhase(db, sessionId, phase) {
198
194
  }
199
195
  function settle(db, s) {
200
196
  return recordTx(db, () => {
197
+ harnessTitled(db, s.sessionId, s.title);
201
198
  db.run("UPDATE sessions SET phase = 'idle', last_settled_at = ?, cwd = ?, project_root = ?, branch = ?, title = coalesce(?, title) WHERE session_id = ?", Date.now(), s.cwd, projectRoot(s.cwd), branch(s.cwd), s.title, s.sessionId);
202
199
  db.run("UPDATE runtimes SET cwd = ?, project_root = ? WHERE tmux_session = ?", s.cwd, projectRoot(s.cwd), s.host);
203
200
  return { archived: isArchived(db, s.sessionId) };
204
201
  });
205
202
  }
203
+ function turnErrored(db, sessionId) {
204
+ recordTx(db, () => db.run("UPDATE sessions SET errored_at = ? WHERE session_id = ?", Date.now(), sessionId));
205
+ }
206
206
  function setTitle(db, sessionId, title) {
207
- recordTx(db, () => db.run("UPDATE sessions SET title = ? WHERE session_id = ?", title, sessionId));
207
+ recordTx(db, () => {
208
+ harnessTitled(db, sessionId, title);
209
+ db.run("UPDATE sessions SET title = ? WHERE session_id = ?", title, sessionId);
210
+ });
211
+ }
212
+ function harnessTitled(db, sessionId, title) {
213
+ if (title === null)
214
+ return;
215
+ db.run("UPDATE marks SET title = NULL WHERE session_id = ? AND ? IS NOT (SELECT title FROM sessions WHERE session_id = ?)", sessionId, title, sessionId);
216
+ }
217
+ function pendingTitle(db, sessionId) {
218
+ const row = db.get("SELECT m.title FROM marks m JOIN sessions s USING (session_id) WHERE m.session_id = ? AND m.title IS NOT NULL AND m.title IS NOT s.title", sessionId);
219
+ return row?.title ?? null;
220
+ }
221
+ function titleOf(db, sessionId) {
222
+ const row = db.get("SELECT coalesce(m.title, s.title) AS title FROM sessions s LEFT JOIN marks m USING (session_id) WHERE s.session_id = ?", sessionId);
223
+ return row?.title ?? null;
208
224
  }
209
225
  function endHosting(db, host) {
210
226
  recordTx(db, () => db.run("DELETE FROM runtimes WHERE tmux_session = ?", host));
@@ -216,18 +232,93 @@ function markArchived(db, sessionId) {
216
232
  if (!db.get("SELECT 1 FROM sessions WHERE session_id = ?", sessionId))
217
233
  return false;
218
234
  const now = Date.now();
219
- db.run("INSERT INTO marks (session_id, archived_at) VALUES (?, ?) ON CONFLICT (session_id) DO UPDATE SET archived_at = excluded.archived_at", sessionId, now);
235
+ db.run(`INSERT INTO marks (session_id, archived_at) VALUES (?, ?)
236
+ ON CONFLICT (session_id) DO UPDATE SET archived_at = excluded.archived_at, snoozed_at = NULL, snoozed_until = NULL`, sessionId, now);
220
237
  db.run("UPDATE messages SET cancelled_at = ? WHERE target_id = ? AND delivered_at IS NULL AND cancelled_at IS NULL", now, sessionId);
238
+ db.run("UPDATE schedules SET cancelled_at = ? WHERE target_id = ? AND next_at IS NOT NULL AND cancelled_at IS NULL", now, sessionId);
221
239
  return true;
222
240
  }
223
241
 
242
+ // packages/shared/src/mailbox.ts
243
+ function doorbells(db) {
244
+ const dir = join2(dirname3(db.path), "doorbells");
245
+ mkdirSync2(dir, { recursive: true, mode: 448 });
246
+ return dir;
247
+ }
248
+ function listen(db, heard) {
249
+ const watcher = watch(doorbells(db), (_event, name) => {
250
+ if (name)
251
+ heard(name.toString());
252
+ });
253
+ watcher.unref();
254
+ return watcher;
255
+ }
256
+ var HARNESS_NAMES = { pi: "pi", claude: "Claude Code", codex: "Codex" };
257
+ function harnessName(harness) {
258
+ return HARNESS_NAMES[harness];
259
+ }
260
+ function pendingFor(db, targetId) {
261
+ return db.all(`SELECT id, kind, source_id, target_id, body, request_response, created_at, schedule_id, scheduled_for FROM messages
262
+ WHERE target_id = ? AND delivered_at IS NULL AND cancelled_at IS NULL ORDER BY created_at, rowid`, targetId).map((row) => ({
263
+ id: row.id,
264
+ kind: row.kind,
265
+ sourceId: row.source_id,
266
+ targetId: row.target_id,
267
+ body: row.body,
268
+ requestResponse: row.request_response === 1,
269
+ createdAt: row.created_at,
270
+ scheduleId: row.schedule_id,
271
+ scheduledFor: row.scheduled_for
272
+ }));
273
+ }
274
+ function markDelivered(db, id) {
275
+ db.run("UPDATE messages SET delivered_at = coalesce(delivered_at, ?) WHERE id = ?", Date.now(), id);
276
+ }
277
+ function sender(db, sourceId) {
278
+ const row = db.get("SELECT harness FROM sessions WHERE session_id = ?", sourceId);
279
+ return { title: titleOf(db, sourceId) ?? sourceId, harness: row?.harness ?? null };
280
+ }
281
+ function clockTime(ms) {
282
+ const d = new Date(ms);
283
+ const day = d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }).replace(",", "");
284
+ return `${day}, ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
285
+ }
286
+ var LATE_MS = 60000;
287
+ function frame(db, m, now = Date.now()) {
288
+ const from = sender(db, m.sourceId);
289
+ const who = m.sourceId === m.targetId ? ", this session" : from.harness ? `, ${harnessName(from.harness)}` : "";
290
+ let when = "";
291
+ if (m.scheduledFor !== null) {
292
+ when = `, scheduled for ${clockTime(m.scheduledFor)}`;
293
+ if (now - m.scheduledFor > LATE_MS)
294
+ when += `, delivered ${clockTime(now)}`;
295
+ if (m.scheduleId)
296
+ when += ` (schedule: ${m.scheduleId})`;
297
+ }
298
+ const reply = m.requestResponse ? `
299
+
300
+ Response requested: reply with session_send_message to session ${m.sourceId}.` : "";
301
+ return `Message from session "${from.title}" (session: ${m.sourceId}${who})${when}:
302
+
303
+ ${m.body}${reply}`;
304
+ }
305
+
224
306
  // packages/shared/src/tools.ts
225
307
  import { Type } from "typebox";
226
308
  import { Value } from "typebox/value";
227
309
  var session = Type.String({ description: "The target session's id, from session_reachable." });
310
+ var requestResponse = Type.Boolean({ description: "Whether the recipient should reply to this session." });
311
+ var cwd = Type.Optional(Type.String({ description: "Its working directory; defaults to this session's." }));
312
+ var title = Type.String({ description: "A short title for the roster." });
313
+ var preset = {
314
+ harness: Type.Optional(Type.Union(HARNESSES.map((name) => Type.Literal(name)), { description: "pi, claude (Claude Code), or codex." })),
315
+ model: Type.Optional(Type.String({ description: "As listed." })),
316
+ effort: Type.Optional(Type.String({ description: "As listed; needed only between presets of one model." })),
317
+ agent: Type.Optional(Type.String({ description: "A Claude Code agent, as listed, in place of model and effort." }))
318
+ };
228
319
  var TOOLS = {
229
320
  session_reachable: {
230
- description: "List every open session on the switchboard, in any harness (pi, Claude Code, Codex), except this one.",
321
+ description: "List every open session on the switchboard except this one.",
231
322
  guidelines: [
232
323
  "Use session_reachable to find another session before messaging, cancelling, or archiving it.",
233
324
  "Sessions in other projects and other harnesses are all reachable; a dormant one wakes when messaged."
@@ -244,46 +335,140 @@ var TOOLS = {
244
335
  parameters: Type.Object({
245
336
  session,
246
337
  message: Type.String({ description: "The message, self-contained: the recipient doesn't see this conversation." }),
247
- requestResponse: Type.Boolean({ description: "Whether the recipient should reply to this session." })
338
+ requestResponse
248
339
  }),
249
340
  readOnly: false
250
341
  },
251
342
  session_cancel: {
252
- description: "Interrupt another session's running turn. The session stays open.",
343
+ description: "Stop another session's running turn. The session stays open.",
253
344
  guidelines: ["Only cancel another session's turn when the user asks for it."],
254
345
  parameters: Type.Object({ session }),
255
346
  readOnly: false
256
347
  },
257
348
  session_launch: {
258
- description: "Start a new session on the switchboard, in pi, Claude Code, or Codex, whose first turn is the goal you give it. It shows up on the user's roster.",
349
+ description: "Start a new session on the switchboard, on one of the presets below.",
259
350
  guidelines: [
260
- "Write the goal as a complete briefing: objective, relevant context, constraints, and what done means. The new session sees nothing else.",
261
- "Launch a new session when the work is independent of this one, belongs to another directory, or the user asks for one."
351
+ "Write the goal as a complete briefing: objective, relevant context, constraints, and what done means.",
352
+ "Launch a new session when the work is independent of this one, belongs to another directory, or the user asks for one.",
353
+ "Omit the preset fields to carry forward what this session runs now. To switch, pass the fields of the preset whose note fits the work; anything not listed is refused."
262
354
  ],
263
355
  parameters: Type.Object({
264
- harness: Type.Union(HARNESSES.map((name) => Type.Literal(name)), { description: "Which harness runs it: pi, claude (Claude Code), or codex." }),
356
+ ...preset,
265
357
  goal: Type.String({ description: "The self-contained briefing, delivered as its first message." }),
266
- cwd: Type.Optional(Type.String({ description: "Its working directory; defaults to this session's." })),
267
- title: Type.String({ description: "A short title for the roster." }),
358
+ cwd,
359
+ title,
268
360
  requestResponse: Type.Boolean({ description: "Whether it should report back to this session when done." })
269
361
  }),
270
362
  readOnly: false
271
363
  },
364
+ session_schedule: {
365
+ description: "Schedule a message for later, once or on a recurring cron schedule: to this session, another session, or a new session launched when it fires.",
366
+ guidelines: [
367
+ "Schedule only when the user asks to remind, follow up, check back, or repeat something. To wait on another session, set requestResponse on your message instead of scheduling checks.",
368
+ "For something expected within a few minutes, keep working in this turn instead.",
369
+ "Write the message as instructions for when it fires: what to check, and what to do with each outcome. A recurring message should say when to unschedule itself.",
370
+ "To message this session, omit both session and launch.",
371
+ "Set snooze only when the user asks for it."
372
+ ],
373
+ parameters: Type.Object({
374
+ message: Type.String({ description: "What to deliver, self-contained when it goes to another session." }),
375
+ at: Type.Optional(Type.String({
376
+ description: 'When, once: an ISO 8601 time, local unless it has an offset ("2026-10-23T15:00"), or a duration from now ("PT2H", "P1D").'
377
+ })),
378
+ cron: Type.Optional(Type.String({
379
+ description: 'Recurring instead: standard 5-field cron in local time, "M H DoM Mon DoW" (e.g. "57 8 * * 1-5" = weekdays at 8:57am).'
380
+ })),
381
+ label: Type.String({ description: 'When it runs, in plain words, such as "Weekdays, 8:57 AM".' }),
382
+ session: Type.Optional(session),
383
+ launch: Type.Optional(Type.Object({
384
+ ...preset,
385
+ cwd,
386
+ title
387
+ }, {
388
+ description: "Instead of an existing session, launch a new one each time it fires, on a preset as session_launch takes it, with the message as its goal."
389
+ })),
390
+ requestResponse: Type.Optional(requestResponse),
391
+ snooze: Type.Optional(Type.Boolean({ description: "Snooze the recipient until this fires." }))
392
+ }),
393
+ readOnly: false
394
+ },
395
+ session_schedules: {
396
+ description: "List this session's schedules, the ones it set and the ones that deliver to it: pending ones with their next run, finished ones with their last.",
397
+ guidelines: [],
398
+ parameters: Type.Object({}),
399
+ readOnly: true
400
+ },
401
+ session_unschedule: {
402
+ description: "Cancel a pending schedule.",
403
+ guidelines: ["Unschedule only when the user asks, or when the schedule's stated completion condition is met."],
404
+ parameters: Type.Object({ id: Type.String({ description: "The schedule's id, from session_schedules." }) }),
405
+ readOnly: false
406
+ },
272
407
  swb_update_session: {
273
- description: "Archive or unarchive a session. Omit session to update the current session.",
408
+ description: "Update one session on the switchboard: this one, unless you name another.",
274
409
  guidelines: [
275
410
  "Archive only when the user asks, or when you started that session and have driven it to completion.",
276
- "Archiving the current session quits it when the turn ends: call it last, then end with a brief final message.",
277
- "Archiving another session fails while it is mid-turn. Unarchiving doesn't restart it; session_send_message wakes it."
411
+ "Archiving this session quits it when the turn ends: call it last, then end with a brief final message.",
412
+ "Archiving refuses any session other than this one that is mid-turn, blocked, cut off, or errored, and names them. If they should go anyway, call again with force, which cancels their turns.",
413
+ "Unarchiving doesn't restart a session; session_send_message wakes it.",
414
+ "Snooze or unsnooze only when the user asks.",
415
+ "Group sessions when the user asks, or to keep sessions you started for one piece of work together. Reuse a Group session_reachable lists in that Project rather than inventing a near-duplicate.",
416
+ "Retitle a session when the user asks, or when its title no longer says what it is working on."
278
417
  ],
279
418
  parameters: Type.Object({
280
- session: Type.Optional(Type.String({ description: "Session id; omit for the current session." })),
281
- archived: Type.Boolean()
419
+ session: Type.Optional(session),
420
+ archived: Type.Optional(Type.Boolean({ description: "True to archive, false to unarchive." })),
421
+ family: Type.Optional(Type.Boolean({ description: "With archived true: archive every open session it started too, and the ones they started." })),
422
+ force: Type.Optional(Type.Boolean({
423
+ description: "With archived true: archive sessions mid-turn, blocked, cut off, or errored anyway, cancelling their turns."
424
+ })),
425
+ group: Type.Optional(Type.Union([Type.String(), Type.Null()], {
426
+ description: "The Group to move it into, within its Project; null takes it out of its Group."
427
+ })),
428
+ title: Type.Optional(Type.String({ description: "A new, short title." })),
429
+ snooze: Type.Optional(Type.Union([...[...SNOOZE_PRESETS, "off"].map((preset) => Type.Literal(preset)), Type.Object({ until: Type.String() })], {
430
+ description: 'How long to snooze it, off to unsnooze, or {"until":"2026-11-05T09:00:00-08:00"} for a set time.'
431
+ }))
432
+ }),
433
+ readOnly: false
434
+ },
435
+ swb_show_session: {
436
+ description: "Show the user a specific session by switching focus to it.",
437
+ guidelines: ["Call it last in your turn, and end with one short line; the user won't be watching this session afterward."],
438
+ parameters: Type.Object({
439
+ session,
440
+ view: Type.Optional(Type.Union(VIEWS.map((view) => Type.Literal(view)), { description: "agent, its directory's editor, or a split of both; omit unless the user asks for one." }))
282
441
  }),
283
442
  readOnly: false
284
443
  }
285
444
  };
286
445
 
446
+ // packages/shared/src/trace.ts
447
+ import { appendFileSync } from "node:fs";
448
+ var file = process.env.SWB_TRACE;
449
+ function now() {
450
+ return performance.timeOrigin + performance.now();
451
+ }
452
+ function write(event) {
453
+ if (file)
454
+ appendFileSync(file, `${JSON.stringify({ ...event, pid: process.pid })}
455
+ `);
456
+ }
457
+ function mark(name) {
458
+ if (file)
459
+ write({ name, at: now() });
460
+ }
461
+ function span(name, run) {
462
+ if (!file)
463
+ return run();
464
+ const at = now();
465
+ const result = run();
466
+ if (result instanceof Promise)
467
+ return result.finally(() => write({ name, at, ms: now() - at }));
468
+ write({ name, at, ms: now() - at });
469
+ return result;
470
+ }
471
+
287
472
  // packages/lines/pi/src/index.ts
288
473
  var KEY = Symbol.for("agent-switchboard.recorder");
289
474
  var store = globalThis;
@@ -328,6 +513,7 @@ function swb(bin, args, stdin = "") {
328
513
  });
329
514
  }
330
515
  function src_default(pi) {
516
+ mark("pi-line.load");
331
517
  pi.on("project_trust", (event) => ({ trusted: event.cwd === operatorDir() ? "yes" : "undecided" }));
332
518
  const claimed = claim();
333
519
  if (!claimed)
@@ -379,7 +565,7 @@ function src_default(pi) {
379
565
  ctx.ui.notify(`swb: that session is already open in another pi (${host})`, "error");
380
566
  return { cancel: true };
381
567
  });
382
- const draftPath = (id) => join2(dirname3(proc.launch.db), "drafts", id);
568
+ const draftPath = (id) => join3(dirname4(proc.launch.db), "drafts", id);
383
569
  const restoreDraft = (ctx, id) => {
384
570
  let draft;
385
571
  try {
@@ -391,7 +577,7 @@ function src_default(pi) {
391
577
  }
392
578
  if (!ctx.ui.getEditorText())
393
579
  ctx.ui.setEditorText(draft);
394
- rmSync(draftPath(id));
580
+ rmSync2(draftPath(id));
395
581
  };
396
582
  let seenVersion = -1;
397
583
  const deliver = () => {
@@ -414,6 +600,9 @@ function src_default(pi) {
414
600
  }
415
601
  markDelivered(db, m.id);
416
602
  }
603
+ const title = pendingTitle(db, current);
604
+ if (title !== null && title !== ctx.sessionManager.getSessionName())
605
+ pi.setSessionName(title);
417
606
  } catch (error) {
418
607
  fail(error);
419
608
  }
@@ -436,7 +625,7 @@ ${details.body}`, 0, 0);
436
625
  pi.appendEntry(LINEAGE, { parentSessionId: proc.parent });
437
626
  delete proc.parent;
438
627
  }
439
- writeFor(ctx, (db, id) => {
628
+ span("pi-line.start-hosting", () => writeFor(ctx, (db, id) => {
440
629
  const transcript = ctx.sessionManager.getSessionFile();
441
630
  if (!transcript)
442
631
  throw new Error("pi is running without a session file");
@@ -449,11 +638,18 @@ ${details.body}`, 0, 0);
449
638
  pid: process.pid,
450
639
  idle: ctx.isIdle()
451
640
  });
452
- });
641
+ }));
453
642
  if (proc.inbox)
454
643
  clearInterval(proc.inbox);
455
644
  proc.inbox = setInterval(deliver, 250);
456
645
  proc.inbox.unref();
646
+ proc.doorbell?.close();
647
+ write(current, (db) => {
648
+ proc.doorbell = listen(db, (id) => {
649
+ if (id === current)
650
+ deliver();
651
+ });
652
+ });
457
653
  });
458
654
  pi.on("agent_start", (_event, ctx) => {
459
655
  writeFor(ctx, (db, id) => {
@@ -465,22 +661,35 @@ ${details.body}`, 0, 0);
465
661
  startTurn(db, { harness: "pi", sessionId: id, cwd: ctx.cwd, transcript, title, parentId });
466
662
  });
467
663
  });
468
- const attention = (open) => (data) => {
469
- const attentionId = data.attentionId;
470
- if (typeof attentionId !== "string" || !current)
664
+ const waiting = (key, open) => {
665
+ if (!current)
471
666
  return;
472
667
  if (open)
473
- proc.attention.add(attentionId);
668
+ proc.attention.add(key);
474
669
  else
475
- proc.attention.delete(attentionId);
670
+ proc.attention.delete(key);
476
671
  const phase = proc.attention.size > 0 ? "blocked" : "working";
477
672
  write(current, (db, id) => setPhase(db, id, phase));
478
673
  };
674
+ const attention = (open) => (data) => {
675
+ const attentionId = data.attentionId;
676
+ if (typeof attentionId === "string")
677
+ waiting(`glimpse:${attentionId}`, open);
678
+ };
479
679
  pi.events.on("glimpseui:attention:request", attention(true));
480
680
  pi.events.on("glimpseui:attention:resolve", attention(false));
481
- pi.on("agent_settled", (_event, ctx) => {
681
+ pi.on("ui_prompt_start", () => waiting("ui-prompt", true));
682
+ pi.on("ui_prompt_end", () => waiting("ui-prompt", false));
683
+ let stopReason;
684
+ pi.on("message_end", (event) => {
685
+ if (event.message.role === "assistant")
686
+ stopReason = event.message.stopReason;
687
+ });
688
+ pi.on("agent_settled", (event, ctx) => {
482
689
  proc.attention.clear();
483
690
  writeFor(ctx, (db, id) => {
691
+ if (stopReason === "error" && !event.aborted)
692
+ turnErrored(db, id);
484
693
  const { archived } = settle(db, {
485
694
  host: proc.launch.tmuxSession,
486
695
  sessionId: id,
@@ -514,11 +723,12 @@ ${details.body}`, 0, 0);
514
723
  ctx.ui.notify("swb: nothing to archive before the first prompt", "error");
515
724
  }
516
725
  });
726
+ const guide = launchGuide();
517
727
  const tool = (name, label, target, run) => pi.registerTool({
518
728
  name,
519
729
  label,
520
- description: TOOLS[name].description,
521
- promptSnippet: TOOLS[name].description,
730
+ description: describeTool(name, TOOLS[name].description, guide),
731
+ promptSnippet: describeTool(name, TOOLS[name].description, guide),
522
732
  promptGuidelines: TOOLS[name].guidelines,
523
733
  parameters: TOOLS[name].parameters,
524
734
  renderCall(args, theme) {
@@ -537,9 +747,9 @@ ${details.body}`, 0, 0);
537
747
  }
538
748
  });
539
749
  const self = (ctx) => ctx.sessionManager.getSessionId();
540
- const titleOf = (id) => typeof id === "string" ? connect().get("SELECT title FROM sessions WHERE session_id = ?", id)?.title ?? id : "";
750
+ const titleOf2 = (id) => typeof id === "string" ? titleOf(connect(), id) ?? id : "";
541
751
  tool("session_reachable", "Reachable sessions", () => "", (_args, ctx) => swb(proc.launch.bin, ["reachable", "--from", self(ctx)]));
542
- tool("session_send_message", "Message", (args) => titleOf(args.session), (args, ctx, toolCallId) => swb(proc.launch.bin, [
752
+ tool("session_send_message", "Message", (args) => titleOf2(args.session), (args, ctx, toolCallId) => swb(proc.launch.bin, [
543
753
  "send",
544
754
  "--from",
545
755
  self(ctx),
@@ -549,11 +759,10 @@ ${details.body}`, 0, 0);
549
759
  toolCallId,
550
760
  ...args.requestResponse ? ["--request-response"] : []
551
761
  ], args.message));
552
- tool("session_cancel", "Cancel", (args) => titleOf(args.session), (args, ctx) => swb(proc.launch.bin, ["send", "--from", self(ctx), "--to", args.session, "--cancel"]));
553
- tool("session_launch", "Launch", (args) => `${args.harness} · ${args.title}`, (args, ctx) => swb(proc.launch.bin, [
762
+ tool("session_cancel", "Cancel", (args) => titleOf2(args.session), (args, ctx) => swb(proc.launch.bin, ["send", "--from", self(ctx), "--to", args.session, "--cancel"]));
763
+ tool("session_launch", "Launch", (args) => `${[args.harness, args.agent, args.model].filter(Boolean).join(" ") || "default"} · ${args.title}`, (args, ctx) => swb(proc.launch.bin, [
554
764
  "launch",
555
- "--harness",
556
- args.harness,
765
+ ...["harness", "model", "effort", "agent"].flatMap((k) => args[k] ? [`--${k}`, args[k]] : []),
557
766
  "--from",
558
767
  self(ctx),
559
768
  "--cwd",
@@ -562,30 +771,57 @@ ${details.body}`, 0, 0);
562
771
  args.title,
563
772
  ...args.requestResponse ? ["--request-response"] : []
564
773
  ], args.goal));
565
- tool("swb_update_session", "Update session", (args) => `${args.archived ? "archive" : "unarchive"} ${args.session ? titleOf(args.session) : "this session"}`, async (args, ctx) => {
566
- const id = self(ctx);
567
- if (!args.session || args.session === id) {
568
- if (!args.archived)
569
- throw new Error("this session is already open");
570
- if (!markArchived2(ctx))
571
- throw new Error(proc.failure ? `swb: not recording: ${proc.failure}` : "swb: no session recorded yet");
572
- ctx.shutdown();
573
- return { text: "Archived. pi quits when this turn ends.", details: { id, archived: true } };
574
- }
575
- return swb(proc.launch.bin, [args.archived ? "archive" : "unarchive", args.session, "--from", id]);
576
- });
774
+ tool("session_schedule", "Schedule", (args) => `${args.launch ? `new ${args.launch.title}` : args.session ? titleOf2(args.session) : "this session"} · ${args.label}${args.snooze ? " · snooze" : ""}`, (args, ctx, toolCallId) => swb(proc.launch.bin, [
775
+ "schedule",
776
+ "--from",
777
+ self(ctx),
778
+ "--label",
779
+ args.label,
780
+ "--request-id",
781
+ toolCallId,
782
+ ...args.at !== undefined ? ["--at", args.at] : [],
783
+ ...args.cron !== undefined ? ["--cron", args.cron] : [],
784
+ ...args.session !== undefined ? ["--to", args.session] : [],
785
+ ...args.launch ? [
786
+ ...["harness", "model", "effort", "agent"].flatMap((k) => args.launch?.[k] ? [`--launch-${k}`, args.launch[k]] : []),
787
+ "--launch-title",
788
+ args.launch.title,
789
+ "--launch-cwd",
790
+ args.launch.cwd ?? ctx.cwd
791
+ ] : [],
792
+ ...args.requestResponse ? ["--request-response"] : [],
793
+ ...args.snooze ? ["--snooze"] : []
794
+ ], args.message));
795
+ tool("session_schedules", "Schedules", () => "", (_args, ctx) => swb(proc.launch.bin, ["schedules", "--from", self(ctx)]));
796
+ tool("session_unschedule", "Unschedule", (args) => String(args.id), (args, ctx) => swb(proc.launch.bin, ["unschedule", args.id, "--from", self(ctx)]));
797
+ tool("swb_show_session", "Show", (args) => `${titleOf2(args.session)}${args.view ? ` · ${args.view}` : ""}`, (args, ctx) => swb(proc.launch.bin, ["show", args.session, "--from", self(ctx), ...args.view ? ["--view", args.view] : []]));
798
+ const snoozeText = (snooze) => typeof snooze === "string" ? snooze : snooze.until;
799
+ tool("swb_update_session", "Update session", (args) => {
800
+ const parts = [
801
+ args.archived === true ? `archive${args.family ? " family" : ""}${args.force ? " · force" : ""}` : "",
802
+ args.archived === false ? "unarchive" : "",
803
+ args.snooze !== undefined ? `snooze ${snoozeText(args.snooze)}` : "",
804
+ args.group === null ? "ungroup" : args.group !== undefined ? `group ${args.group}` : "",
805
+ args.title !== undefined ? `retitle as "${args.title}"` : ""
806
+ ].filter(Boolean);
807
+ if (args.title !== undefined)
808
+ return parts.join(" · ");
809
+ return `${parts.join(" · ")} ${args.session ? titleOf2(args.session) : "this session"}`;
810
+ }, (args, ctx) => swb(proc.launch.bin, ["update", "--from", self(ctx)], JSON.stringify(args)));
577
811
  pi.on("session_shutdown", (event, ctx) => {
578
812
  if (proc.inbox)
579
813
  clearInterval(proc.inbox);
580
814
  proc.inbox = undefined;
815
+ proc.doorbell?.close();
816
+ proc.doorbell = undefined;
581
817
  ctxNow = undefined;
582
818
  if (event.reason !== "quit")
583
819
  return;
584
820
  const draft = ctx.ui.getEditorText();
585
821
  if (draft.trim()) {
586
822
  const path = draftPath(ctx.sessionManager.getSessionId());
587
- mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
588
- writeFileSync(path, draft, { mode: 384 });
823
+ mkdirSync3(dirname4(path), { recursive: true, mode: 448 });
824
+ writeFileSync2(path, draft, { mode: 384 });
589
825
  }
590
826
  writeFor(ctx, (db) => endHosting(db, proc.launch.tmuxSession));
591
827
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thurstonsand/pi-agent-switchboard",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Records managed pi sessions for Agent Switchboard (swb)",
6
6
  "license": "MIT",