@thurstonsand/pi-agent-switchboard 0.5.0 → 0.6.2

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 +327 -114
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,8 +1,7 @@
1
- // packages/pi/src/index.ts
2
- import { execFile } from "node:child_process";
1
+ // packages/lines/pi/src/index.ts
2
+ import { spawn } from "node:child_process";
3
3
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname as dirname3, join as join2 } from "node:path";
5
- import { promisify } from "node:util";
6
5
  import { Text } from "@earendil-works/pi-tui";
7
6
 
8
7
  // packages/shared/src/index.ts
@@ -37,7 +36,7 @@ class Db {
37
36
  throw new Error(`no db at ${path}`);
38
37
  }
39
38
  this.#driver = connect(path);
40
- this.#driver.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;");
39
+ this.#driver.exec("PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;");
41
40
  }
42
41
  get(sql, ...params) {
43
42
  return this.#driver.prepare(sql).get(...params) ?? undefined;
@@ -74,7 +73,8 @@ class Db {
74
73
  }
75
74
 
76
75
  // packages/shared/src/index.ts
77
- var SCHEMA_VERSION = 3;
76
+ var SCHEMA_VERSION = 5;
77
+ var HARNESSES = ["pi", "claude", "codex"];
78
78
  function configDir() {
79
79
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "agent-switchboard");
80
80
  }
@@ -117,60 +117,215 @@ function branch(cwd) {
117
117
  return name === "HEAD" ? git(cwd, "rev-parse", "--short", "HEAD") : name;
118
118
  }
119
119
 
120
- // packages/pi/src/index.ts
120
+ // 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
+ }
154
+
155
+ // packages/shared/src/record.ts
156
+ function recordTx(db, body) {
157
+ return db.tx(() => {
158
+ const version = db.userVersion();
159
+ if (version !== SCHEMA_VERSION)
160
+ throw new Error(`db schema is v${version}, this line writes v${SCHEMA_VERSION}`);
161
+ return body();
162
+ });
163
+ }
164
+ function liveHost(row) {
165
+ if (row && row.boot_id === bootId() && pidAlive(row.pid))
166
+ return row.tmux_session;
167
+ return;
168
+ }
169
+ function hostOfSession(db, sessionId, exceptHost) {
170
+ return liveHost(db.get("SELECT tmux_session, boot_id, pid FROM runtimes WHERE session_id = ? AND tmux_session != ?", sessionId, exceptHost));
171
+ }
172
+ function hostOfTranscript(db, transcript, exceptHost) {
173
+ return liveHost(db.get("SELECT r.tmux_session, r.boot_id, r.pid FROM runtimes r JOIN sessions s USING (session_id) WHERE s.transcript = ? AND r.tmux_session != ?", transcript, exceptHost));
174
+ }
175
+ function startHosting(db, h) {
176
+ recordTx(db, () => {
177
+ const other = hostOfSession(db, h.sessionId, h.host);
178
+ if (other)
179
+ throw new Error(`session ${h.sessionId} is already open in another process (${other})`);
180
+ db.run("DELETE FROM runtimes WHERE session_id = ? AND tmux_session != ?", h.sessionId, h.host);
181
+ db.run(`INSERT INTO runtimes (tmux_session, session_id, harness, cwd, project_root, boot_id, pid, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
182
+ ON CONFLICT (tmux_session) DO UPDATE SET session_id = excluded.session_id, harness = excluded.harness, cwd = excluded.cwd,
183
+ project_root = excluded.project_root, boot_id = excluded.boot_id, pid = excluded.pid`, h.host, h.sessionId, h.harness, h.cwd, projectRoot(h.cwd), bootId(), h.pid, Date.now());
184
+ db.run("UPDATE sessions SET phase = CASE WHEN ? THEN 'idle' ELSE phase END, cwd = ?, project_root = ?, branch = ?, transcript = ? WHERE session_id = ?", h.idle ? 1 : 0, h.cwd, projectRoot(h.cwd), branch(h.cwd), h.transcript, h.sessionId);
185
+ });
186
+ }
187
+ function startTurn(db, t) {
188
+ recordTx(db, () => {
189
+ const now = Date.now();
190
+ db.run(`INSERT INTO sessions (session_id, harness, parent_id, cwd, project_root, transcript, title, branch, phase, created_at, last_prompt_at)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'working', ?, ?)
192
+ ON CONFLICT (session_id) DO UPDATE SET phase = 'working',
193
+ 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);
194
+ });
195
+ }
196
+ function setPhase(db, sessionId, phase) {
197
+ recordTx(db, () => db.run("UPDATE sessions SET phase = ? WHERE session_id = ? AND phase != 'idle'", phase, sessionId));
198
+ }
199
+ function settle(db, s) {
200
+ return recordTx(db, () => {
201
+ 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
+ db.run("UPDATE runtimes SET cwd = ?, project_root = ? WHERE tmux_session = ?", s.cwd, projectRoot(s.cwd), s.host);
203
+ return { archived: isArchived(db, s.sessionId) };
204
+ });
205
+ }
206
+ function setTitle(db, sessionId, title) {
207
+ recordTx(db, () => db.run("UPDATE sessions SET title = ? WHERE session_id = ?", title, sessionId));
208
+ }
209
+ function endHosting(db, host) {
210
+ recordTx(db, () => db.run("DELETE FROM runtimes WHERE tmux_session = ?", host));
211
+ }
212
+ function isArchived(db, sessionId) {
213
+ return Boolean(db.get("SELECT 1 FROM sessions s JOIN marks m USING (session_id) WHERE s.session_id = ? AND m.archived_at >= s.last_prompt_at", sessionId));
214
+ }
215
+ function markArchived(db, sessionId) {
216
+ if (!db.get("SELECT 1 FROM sessions WHERE session_id = ?", sessionId))
217
+ return false;
218
+ 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);
220
+ db.run("UPDATE messages SET cancelled_at = ? WHERE target_id = ? AND delivered_at IS NULL AND cancelled_at IS NULL", now, sessionId);
221
+ return true;
222
+ }
223
+
224
+ // packages/shared/src/tools.ts
121
225
  import { Type } from "typebox";
226
+ import { Value } from "typebox/value";
227
+ var session = Type.String({ description: "The target session's id, from session_reachable." });
228
+ var TOOLS = {
229
+ session_reachable: {
230
+ description: "List every open session on the switchboard, in any harness (pi, Claude Code, Codex), except this one.",
231
+ guidelines: [
232
+ "Use session_reachable to find another session before messaging, cancelling, or archiving it.",
233
+ "Sessions in other projects and other harnesses are all reachable; a dormant one wakes when messaged."
234
+ ],
235
+ parameters: Type.Object({}),
236
+ readOnly: true
237
+ },
238
+ session_send_message: {
239
+ description: "Send a message to another session on the switchboard. It arrives in that session's conversation, mid-turn when it is working; a dormant session is woken first.",
240
+ guidelines: [
241
+ "Messages are for coordinating with the session that owns the work; don't send what you can do yourself.",
242
+ "Set requestResponse when you need an answer; the reply arrives later as a message to you, so end your turn instead of polling."
243
+ ],
244
+ parameters: Type.Object({
245
+ session,
246
+ 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." })
248
+ }),
249
+ readOnly: false
250
+ },
251
+ session_cancel: {
252
+ description: "Interrupt another session's running turn. The session stays open.",
253
+ guidelines: ["Only cancel another session's turn when the user asks for it."],
254
+ parameters: Type.Object({ session }),
255
+ readOnly: false
256
+ },
257
+ 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.",
259
+ 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."
262
+ ],
263
+ parameters: Type.Object({
264
+ harness: Type.Union(HARNESSES.map((name) => Type.Literal(name)), { description: "Which harness runs it: pi, claude (Claude Code), or codex." }),
265
+ 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." }),
268
+ requestResponse: Type.Boolean({ description: "Whether it should report back to this session when done." })
269
+ }),
270
+ readOnly: false
271
+ },
272
+ swb_update_session: {
273
+ description: "Archive or unarchive a session. Omit session to update the current session.",
274
+ guidelines: [
275
+ "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."
278
+ ],
279
+ parameters: Type.Object({
280
+ session: Type.Optional(Type.String({ description: "Session id; omit for the current session." })),
281
+ archived: Type.Boolean()
282
+ }),
283
+ readOnly: false
284
+ }
285
+ };
286
+
287
+ // packages/lines/pi/src/index.ts
122
288
  var KEY = Symbol.for("agent-switchboard.recorder");
123
289
  var store = globalThis;
290
+ var MESSAGE = "swb.message";
291
+ var LINEAGE = "pi-sessions.lineage";
124
292
  function claim() {
125
293
  const env = process.env;
126
294
  if (env.SWB_MANAGED === "1" && env.SWB_DB && env.SWB_TMUX_SESSION) {
127
295
  store[KEY] = {
128
296
  launch: Object.freeze({ db: env.SWB_DB, tmuxSession: env.SWB_TMUX_SESSION, bin: env.SWB_BIN ?? "" }),
129
297
  attention: new Set,
130
- ...env.SWB_BIN ? {} : { failure: "started by an swb older than this recorder; upgrade swb" }
298
+ ...env.SWB_PARENT ? { parent: env.SWB_PARENT } : {},
299
+ ...env.SWB_BIN ? {} : { failure: "started by an swb older than this line; upgrade swb" }
131
300
  };
132
301
  }
133
302
  delete env.SWB_MANAGED;
134
303
  delete env.SWB_DB;
135
304
  delete env.SWB_TMUX_SESSION;
136
305
  delete env.SWB_BIN;
306
+ delete env.SWB_PARENT;
137
307
  return store[KEY];
138
308
  }
139
- var run = promisify(execFile);
140
- function host(bin) {
141
- const swb = async (...args) => {
142
- try {
143
- return (await run(bin, args, { timeout: 15000 })).stdout;
144
- } catch (error) {
145
- const { stderr, message } = error;
146
- throw new Error(stderr?.trim() || message);
147
- }
148
- };
149
- return {
150
- name: "swb",
151
- async launch(input) {
152
- await swb("launch", "--cwd", input.cwd, "--session-id", input.sessionId, "--model", input.model);
153
- return { success: true };
154
- },
155
- async listSessions() {
156
- const sessions = JSON.parse(await swb("ls", "--json"));
157
- return sessions.filter((s) => s.open).map((s) => ({ sessionId: s.id, cwd: s.cwd, title: s.title ?? s.id }));
158
- },
159
- async wake(sessionId) {
160
- await swb("wake", sessionId);
161
- }
162
- };
163
- }
164
- function liveHost(row) {
165
- if (row && row.boot_id === bootId() && pidAlive(row.pid))
166
- return row.tmux_session;
167
- return;
168
- }
169
- function hostOfSession(db, proc, sessionId) {
170
- return liveHost(db.get("SELECT tmux_session, boot_id, pid FROM runtimes WHERE session_id = ? AND tmux_session != ?", sessionId, proc.launch.tmuxSession));
171
- }
172
- function hostOfTranscript(db, proc, transcript) {
173
- return liveHost(db.get("SELECT r.tmux_session, r.boot_id, r.pid FROM runtimes r JOIN sessions s USING (session_id) WHERE s.transcript = ? AND r.tmux_session != ?", transcript, proc.launch.tmuxSession));
309
+ function swb(bin, args, stdin = "") {
310
+ return new Promise((resolve, reject) => {
311
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
312
+ let out = "";
313
+ let err = "";
314
+ child.stdout.on("data", (chunk) => {
315
+ out += chunk;
316
+ });
317
+ child.stderr.on("data", (chunk) => {
318
+ err += chunk;
319
+ });
320
+ child.on("error", reject);
321
+ child.on("close", (code) => {
322
+ if (code === 0)
323
+ resolve(JSON.parse(out));
324
+ else
325
+ reject(new Error(err.trim().replace(/^swb: /, "") || `swb ${args[0]} exited ${code}`));
326
+ });
327
+ child.stdin.end(stdin);
328
+ });
174
329
  }
175
330
  function src_default(pi) {
176
331
  pi.on("project_trust", (event) => ({ trusted: event.cwd === operatorDir() ? "yes" : "undecided" }));
@@ -179,8 +334,8 @@ function src_default(pi) {
179
334
  return;
180
335
  const proc = claimed;
181
336
  let ui;
182
- if (proc.launch.bin)
183
- pi.events.on("pi-sessions:hosts:v1", (request) => request.register(host(proc.launch.bin)));
337
+ let ctxNow;
338
+ pi.events.on("pi-sessions:hosts", (request) => request.register({ name: "swb", peers: true }));
184
339
  const showFailure = () => ui?.setStatus("swb", ui.theme.fg("error", `swb: not recording: ${proc.failure}`));
185
340
  const connect = () => {
186
341
  proc.db ??= new Db(proc.launch.db, { create: false });
@@ -197,13 +352,7 @@ function src_default(pi) {
197
352
  return;
198
353
  }
199
354
  try {
200
- const db = connect();
201
- db.tx(() => {
202
- const version = db.userVersion();
203
- if (version !== SCHEMA_VERSION)
204
- throw new Error(`db schema is v${version}, this recorder writes v${SCHEMA_VERSION}`);
205
- body(db, sessionId);
206
- });
355
+ body(connect(), sessionId);
207
356
  } catch (error) {
208
357
  fail(error);
209
358
  }
@@ -213,13 +362,14 @@ function src_default(pi) {
213
362
  write(ctx.sessionManager.getSessionId(), body);
214
363
  };
215
364
  let current;
365
+ let launchedBy;
216
366
  pi.on("session_before_switch", (event, ctx) => {
217
367
  if (event.reason !== "resume" || !event.targetSessionFile || proc.failure)
218
368
  return;
219
369
  ui = ctx.ui;
220
370
  let host;
221
371
  try {
222
- host = hostOfTranscript(connect(), proc, event.targetSessionFile);
372
+ host = hostOfTranscript(connect(), event.targetSessionFile, proc.launch.tmuxSession);
223
373
  } catch (error) {
224
374
  fail(error);
225
375
  return;
@@ -243,35 +393,76 @@ function src_default(pi) {
243
393
  ctx.ui.setEditorText(draft);
244
394
  rmSync(draftPath(id));
245
395
  };
396
+ let seenVersion = -1;
397
+ const deliver = () => {
398
+ const ctx = ctxNow;
399
+ if (!ctx || !current || proc.failure)
400
+ return;
401
+ try {
402
+ const db = connect();
403
+ const version = db.dataVersion();
404
+ if (version === seenVersion)
405
+ return;
406
+ seenVersion = version;
407
+ for (const m of pendingFor(db, current)) {
408
+ if (m.kind === "cancel") {
409
+ if (!ctx.isIdle())
410
+ ctx.abort();
411
+ } else {
412
+ const details = { from: m.sourceId, title: sender(db, m.sourceId).title, body: m.body };
413
+ pi.sendMessage({ customType: MESSAGE, content: frame(db, m), display: true, details }, ctx.isIdle() ? { triggerTurn: true } : { deliverAs: "steer" });
414
+ }
415
+ markDelivered(db, m.id);
416
+ }
417
+ } catch (error) {
418
+ fail(error);
419
+ }
420
+ };
421
+ pi.registerMessageRenderer(MESSAGE, (message, _options, theme) => {
422
+ const details = message.details;
423
+ return new Text(`${theme.fg("accent", `↘ ${details.title}`)}
424
+ ${details.body}`, 0, 0);
425
+ });
246
426
  pi.on("session_start", (event, ctx) => {
247
427
  current = ctx.sessionManager.getSessionId();
428
+ ctxNow = ctx;
429
+ seenVersion = -1;
248
430
  if (event.reason !== "reload")
249
431
  restoreDraft(ctx, current);
250
432
  proc.attention.clear();
433
+ if (proc.parent) {
434
+ launchedBy = { sessionId: current, parent: proc.parent };
435
+ if (!ctx.sessionManager.getEntries().some((entry) => entry.type === "custom" && entry.customType === LINEAGE))
436
+ pi.appendEntry(LINEAGE, { parentSessionId: proc.parent });
437
+ delete proc.parent;
438
+ }
251
439
  writeFor(ctx, (db, id) => {
252
- const host = hostOfSession(db, proc, id);
253
- if (host)
254
- throw new Error(`session ${id} is already open in another pi (${host})`);
255
- db.run("DELETE FROM runtimes WHERE session_id = ? AND tmux_session != ?", id, proc.launch.tmuxSession);
256
- db.run(`INSERT INTO runtimes (tmux_session, session_id, cwd, project_root, boot_id, pid, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)
257
- ON CONFLICT (tmux_session) DO UPDATE SET session_id = excluded.session_id, cwd = excluded.cwd,
258
- project_root = excluded.project_root, boot_id = excluded.boot_id, pid = excluded.pid`, proc.launch.tmuxSession, id, ctx.cwd, projectRoot(ctx.cwd), bootId(), process.pid, Date.now());
259
440
  const transcript = ctx.sessionManager.getSessionFile();
260
441
  if (!transcript)
261
442
  throw new Error("pi is running without a session file");
262
- db.run("UPDATE sessions SET phase = 'idle', cwd = ?, project_root = ?, branch = ?, transcript = ? WHERE session_id = ?", ctx.cwd, projectRoot(ctx.cwd), branch(ctx.cwd), transcript, id);
443
+ startHosting(db, {
444
+ host: proc.launch.tmuxSession,
445
+ harness: "pi",
446
+ sessionId: id,
447
+ cwd: ctx.cwd,
448
+ transcript,
449
+ pid: process.pid,
450
+ idle: ctx.isIdle()
451
+ });
263
452
  });
453
+ if (proc.inbox)
454
+ clearInterval(proc.inbox);
455
+ proc.inbox = setInterval(deliver, 250);
456
+ proc.inbox.unref();
264
457
  });
265
458
  pi.on("agent_start", (_event, ctx) => {
266
459
  writeFor(ctx, (db, id) => {
267
460
  const transcript = ctx.sessionManager.getSessionFile();
268
461
  if (!transcript)
269
462
  throw new Error("pi is running without a session file");
270
- const now = Date.now();
271
- db.run(`INSERT INTO sessions (session_id, tool, cwd, project_root, transcript, title, branch, phase, created_at, last_prompt_at)
272
- VALUES (?, 'pi', ?, ?, ?, ?, ?, 'working', ?, ?)
273
- ON CONFLICT (session_id) DO UPDATE SET phase = 'working',
274
- last_prompt_at = CASE WHEN sessions.phase = 'idle' THEN excluded.last_prompt_at ELSE sessions.last_prompt_at END`, id, ctx.cwd, projectRoot(ctx.cwd), transcript, ctx.sessionManager.getSessionName() ?? null, branch(ctx.cwd), now, now);
463
+ const title = ctx.sessionManager.getSessionName() ?? null;
464
+ const parentId = launchedBy?.sessionId === id ? launchedBy.parent : null;
465
+ startTurn(db, { harness: "pi", sessionId: id, cwd: ctx.cwd, transcript, title, parentId });
275
466
  });
276
467
  });
277
468
  const attention = (open) => (data) => {
@@ -283,27 +474,30 @@ function src_default(pi) {
283
474
  else
284
475
  proc.attention.delete(attentionId);
285
476
  const phase = proc.attention.size > 0 ? "blocked" : "working";
286
- write(current, (db, id) => db.run("UPDATE sessions SET phase = ? WHERE session_id = ? AND phase != 'idle'", phase, id));
477
+ write(current, (db, id) => setPhase(db, id, phase));
287
478
  };
288
479
  pi.events.on("glimpseui:attention:request", attention(true));
289
480
  pi.events.on("glimpseui:attention:resolve", attention(false));
290
481
  pi.on("agent_settled", (_event, ctx) => {
291
482
  proc.attention.clear();
292
483
  writeFor(ctx, (db, id) => {
293
- db.run("UPDATE sessions SET phase = 'idle', last_settled_at = ?, cwd = ?, project_root = ?, branch = ?, title = ? WHERE session_id = ?", Date.now(), ctx.cwd, projectRoot(ctx.cwd), branch(ctx.cwd), ctx.sessionManager.getSessionName() ?? null, id);
294
- db.run("UPDATE runtimes SET cwd = ?, project_root = ? WHERE tmux_session = ?", ctx.cwd, projectRoot(ctx.cwd), proc.launch.tmuxSession);
484
+ const { archived } = settle(db, {
485
+ host: proc.launch.tmuxSession,
486
+ sessionId: id,
487
+ cwd: ctx.cwd,
488
+ title: ctx.sessionManager.getSessionName() ?? null
489
+ });
490
+ if (archived)
491
+ ctx.shutdown();
295
492
  });
296
493
  });
297
494
  pi.on("session_info_changed", (event, ctx) => {
298
- writeFor(ctx, (db, id) => db.run("UPDATE sessions SET title = ? WHERE session_id = ?", event.name ?? null, id));
495
+ writeFor(ctx, (db, id) => setTitle(db, id, event.name ?? null));
299
496
  });
300
- const markArchived = (ctx) => {
497
+ const markArchived2 = (ctx) => {
301
498
  let archived = false;
302
499
  writeFor(ctx, (db, id) => {
303
- if (!db.get("SELECT 1 FROM sessions WHERE session_id = ?", id))
304
- return;
305
- db.run("INSERT INTO marks (session_id, archived_at) VALUES (?, ?) ON CONFLICT (session_id) DO UPDATE SET archived_at = excluded.archived_at", id, Date.now());
306
- archived = true;
500
+ archived = recordTx(db, () => markArchived(db, id));
307
501
  });
308
502
  return archived;
309
503
  };
@@ -314,58 +508,77 @@ function src_default(pi) {
314
508
  ctx.ui.notify("swb: turn running: wait for it to complete", "error");
315
509
  return;
316
510
  }
317
- if (markArchived(ctx))
511
+ if (markArchived2(ctx))
318
512
  ctx.shutdown();
319
513
  else if (!proc.failure)
320
514
  ctx.ui.notify("swb: nothing to archive before the first prompt", "error");
321
515
  }
322
516
  });
323
- pi.registerTool({
324
- name: "swb_update_session",
325
- label: "Update session",
326
- description: "Archive or unarchive a session. Omit session to update the current session.",
327
- promptSnippet: "Archive or unarchive a session",
328
- promptGuidelines: [
329
- "Archive only when the user asks, or when you started that session and have driven it to completion.",
330
- "Archiving the current session will quit it when the turn ends: call it last, then end with a brief final message.",
331
- "Archiving another session fails while it is mid-turn. Unarchiving doesn't restart it; session_send_message wakes it."
332
- ],
333
- parameters: Type.Object({
334
- session: Type.Optional(Type.String({ description: "Session id; omit for the current session" })),
335
- archived: Type.Boolean()
336
- }),
517
+ const tool = (name, label, target, run) => pi.registerTool({
518
+ name,
519
+ label,
520
+ description: TOOLS[name].description,
521
+ promptSnippet: TOOLS[name].description,
522
+ promptGuidelines: TOOLS[name].guidelines,
523
+ parameters: TOOLS[name].parameters,
337
524
  renderCall(args, theme) {
338
- const target = !args.session || args.session === current ? "This session" : connect().get("SELECT title FROM sessions WHERE session_id = ?", args.session)?.title ?? args.session;
339
- return new Text(`${theme.fg("toolTitle", theme.bold(args.archived ? "Archive" : "Unarchive"))} · ${theme.fg("accent", target)}`, 0, 0);
525
+ const what = target(args);
526
+ return new Text(`${theme.fg("toolTitle", theme.bold(label))}${what ? ` · ${theme.fg("accent", what)}` : ""}`, 0, 0);
340
527
  },
341
528
  renderResult(result, _options, theme, context) {
342
529
  const output = result.content.find((item) => item.type === "text")?.text ?? "";
343
- return new Text(context.isError ? theme.fg("error", output) : "", 0, 0);
530
+ return new Text(context.isError ? theme.fg("error", output) : theme.fg("dim", output), 0, 0);
344
531
  },
345
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
346
- const self = ctx.sessionManager.getSessionId();
347
- if (!params.session || params.session === self) {
348
- if (!params.archived)
349
- throw new Error("this session is already open");
350
- if (!markArchived(ctx)) {
351
- throw new Error(proc.failure ? `swb: not recording: ${proc.failure}` : "swb: no session recorded yet");
352
- }
353
- ctx.shutdown();
354
- return { content: [{ type: "text", text: "Archived. pi quits when this turn ends." }], details: undefined };
355
- }
356
- try {
357
- await run(proc.launch.bin, [params.archived ? "archive" : "unarchive", params.session], { timeout: 15000 });
358
- } catch (error) {
359
- const { stderr, message } = error;
360
- throw new Error(stderr?.trim() || message);
361
- }
362
- return {
363
- content: [{ type: "text", text: params.archived ? "Archived." : "Unarchived. It stays dormant until messaged." }],
364
- details: undefined
365
- };
532
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
533
+ if (proc.failure)
534
+ throw new Error(`swb: not recording: ${proc.failure}`);
535
+ const result = await run(params, ctx, toolCallId);
536
+ return { content: [{ type: "text", text: result.text }], details: result.details };
537
+ }
538
+ });
539
+ 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 : "";
541
+ 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, [
543
+ "send",
544
+ "--from",
545
+ self(ctx),
546
+ "--to",
547
+ args.session,
548
+ "--request-id",
549
+ toolCallId,
550
+ ...args.requestResponse ? ["--request-response"] : []
551
+ ], 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, [
554
+ "launch",
555
+ "--harness",
556
+ args.harness,
557
+ "--from",
558
+ self(ctx),
559
+ "--cwd",
560
+ args.cwd ?? ctx.cwd,
561
+ "--title",
562
+ args.title,
563
+ ...args.requestResponse ? ["--request-response"] : []
564
+ ], 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 } };
366
574
  }
575
+ return swb(proc.launch.bin, [args.archived ? "archive" : "unarchive", args.session, "--from", id]);
367
576
  });
368
577
  pi.on("session_shutdown", (event, ctx) => {
578
+ if (proc.inbox)
579
+ clearInterval(proc.inbox);
580
+ proc.inbox = undefined;
581
+ ctxNow = undefined;
369
582
  if (event.reason !== "quit")
370
583
  return;
371
584
  const draft = ctx.ui.getEditorText();
@@ -374,7 +587,7 @@ function src_default(pi) {
374
587
  mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
375
588
  writeFileSync(path, draft, { mode: 384 });
376
589
  }
377
- writeFor(ctx, (db) => db.run("DELETE FROM runtimes WHERE tmux_session = ?", proc.launch.tmuxSession));
590
+ writeFor(ctx, (db) => endHosting(db, proc.launch.tmuxSession));
378
591
  });
379
592
  }
380
593
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thurstonsand/pi-agent-switchboard",
3
- "version": "0.5.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Records managed pi sessions for Agent Switchboard (swb)",
6
6
  "license": "MIT",