@thurstonsand/pi-agent-switchboard 0.4.0 → 0.6.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.
Files changed (2) hide show
  1. package/index.js +322 -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,210 @@ 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
+ },
237
+ session_send_message: {
238
+ 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.",
239
+ guidelines: [
240
+ "Messages are for coordinating with the session that owns the work; don't send what you can do yourself.",
241
+ "Set requestResponse when you need an answer; the reply arrives later as a message to you, so end your turn instead of polling."
242
+ ],
243
+ parameters: Type.Object({
244
+ session,
245
+ message: Type.String({ description: "The message, self-contained: the recipient doesn't see this conversation." }),
246
+ requestResponse: Type.Boolean({ description: "Whether the recipient should reply to this session." })
247
+ })
248
+ },
249
+ session_cancel: {
250
+ description: "Interrupt another session's running turn. The session stays open.",
251
+ guidelines: ["Only cancel another session's turn when the user asks for it."],
252
+ parameters: Type.Object({ session })
253
+ },
254
+ session_launch: {
255
+ 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.",
256
+ guidelines: [
257
+ "Write the goal as a complete briefing: objective, relevant context, constraints, and what done means. The new session sees nothing else.",
258
+ "Launch a new session when the work is independent of this one, belongs to another directory, or the user asks for one."
259
+ ],
260
+ parameters: Type.Object({
261
+ harness: Type.Union(HARNESSES.map((name) => Type.Literal(name)), { description: "Which harness runs it: pi, claude (Claude Code), or codex." }),
262
+ goal: Type.String({ description: "The self-contained briefing, delivered as its first message." }),
263
+ cwd: Type.Optional(Type.String({ description: "Its working directory; defaults to this session's." })),
264
+ title: Type.String({ description: "A short title for the roster." }),
265
+ requestResponse: Type.Boolean({ description: "Whether it should report back to this session when done." })
266
+ })
267
+ },
268
+ swb_update_session: {
269
+ description: "Archive or unarchive a session. Omit session to update the current session.",
270
+ guidelines: [
271
+ "Archive only when the user asks, or when you started that session and have driven it to completion.",
272
+ "Archiving the current session quits it when the turn ends: call it last, then end with a brief final message.",
273
+ "Archiving another session fails while it is mid-turn. Unarchiving doesn't restart it; session_send_message wakes it."
274
+ ],
275
+ parameters: Type.Object({
276
+ session: Type.Optional(Type.String({ description: "Session id; omit for the current session." })),
277
+ archived: Type.Boolean()
278
+ })
279
+ }
280
+ };
281
+
282
+ // packages/lines/pi/src/index.ts
122
283
  var KEY = Symbol.for("agent-switchboard.recorder");
123
284
  var store = globalThis;
285
+ var MESSAGE = "swb.message";
286
+ var LINEAGE = "pi-sessions.lineage";
124
287
  function claim() {
125
288
  const env = process.env;
126
289
  if (env.SWB_MANAGED === "1" && env.SWB_DB && env.SWB_TMUX_SESSION) {
127
290
  store[KEY] = {
128
291
  launch: Object.freeze({ db: env.SWB_DB, tmuxSession: env.SWB_TMUX_SESSION, bin: env.SWB_BIN ?? "" }),
129
292
  attention: new Set,
130
- ...env.SWB_BIN ? {} : { failure: "started by an swb older than this recorder; upgrade swb" }
293
+ ...env.SWB_PARENT ? { parent: env.SWB_PARENT } : {},
294
+ ...env.SWB_BIN ? {} : { failure: "started by an swb older than this line; upgrade swb" }
131
295
  };
132
296
  }
133
297
  delete env.SWB_MANAGED;
134
298
  delete env.SWB_DB;
135
299
  delete env.SWB_TMUX_SESSION;
136
300
  delete env.SWB_BIN;
301
+ delete env.SWB_PARENT;
137
302
  return store[KEY];
138
303
  }
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));
304
+ function swb(bin, args, stdin = "") {
305
+ return new Promise((resolve, reject) => {
306
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
307
+ let out = "";
308
+ let err = "";
309
+ child.stdout.on("data", (chunk) => {
310
+ out += chunk;
311
+ });
312
+ child.stderr.on("data", (chunk) => {
313
+ err += chunk;
314
+ });
315
+ child.on("error", reject);
316
+ child.on("close", (code) => {
317
+ if (code === 0)
318
+ resolve(JSON.parse(out));
319
+ else
320
+ reject(new Error(err.trim().replace(/^swb: /, "") || `swb ${args[0]} exited ${code}`));
321
+ });
322
+ child.stdin.end(stdin);
323
+ });
174
324
  }
175
325
  function src_default(pi) {
176
326
  pi.on("project_trust", (event) => ({ trusted: event.cwd === operatorDir() ? "yes" : "undecided" }));
@@ -179,8 +329,8 @@ function src_default(pi) {
179
329
  return;
180
330
  const proc = claimed;
181
331
  let ui;
182
- if (proc.launch.bin)
183
- pi.events.on("pi-sessions:hosts:v1", (request) => request.register(host(proc.launch.bin)));
332
+ let ctxNow;
333
+ pi.events.on("pi-sessions:hosts", (request) => request.register({ name: "swb", peers: true }));
184
334
  const showFailure = () => ui?.setStatus("swb", ui.theme.fg("error", `swb: not recording: ${proc.failure}`));
185
335
  const connect = () => {
186
336
  proc.db ??= new Db(proc.launch.db, { create: false });
@@ -197,13 +347,7 @@ function src_default(pi) {
197
347
  return;
198
348
  }
199
349
  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
- });
350
+ body(connect(), sessionId);
207
351
  } catch (error) {
208
352
  fail(error);
209
353
  }
@@ -213,13 +357,14 @@ function src_default(pi) {
213
357
  write(ctx.sessionManager.getSessionId(), body);
214
358
  };
215
359
  let current;
360
+ let launchedBy;
216
361
  pi.on("session_before_switch", (event, ctx) => {
217
362
  if (event.reason !== "resume" || !event.targetSessionFile || proc.failure)
218
363
  return;
219
364
  ui = ctx.ui;
220
365
  let host;
221
366
  try {
222
- host = hostOfTranscript(connect(), proc, event.targetSessionFile);
367
+ host = hostOfTranscript(connect(), event.targetSessionFile, proc.launch.tmuxSession);
223
368
  } catch (error) {
224
369
  fail(error);
225
370
  return;
@@ -243,35 +388,76 @@ function src_default(pi) {
243
388
  ctx.ui.setEditorText(draft);
244
389
  rmSync(draftPath(id));
245
390
  };
391
+ let seenVersion = -1;
392
+ const deliver = () => {
393
+ const ctx = ctxNow;
394
+ if (!ctx || !current || proc.failure)
395
+ return;
396
+ try {
397
+ const db = connect();
398
+ const version = db.dataVersion();
399
+ if (version === seenVersion)
400
+ return;
401
+ seenVersion = version;
402
+ for (const m of pendingFor(db, current)) {
403
+ if (m.kind === "cancel") {
404
+ if (!ctx.isIdle())
405
+ ctx.abort();
406
+ } else {
407
+ const details = { from: m.sourceId, title: sender(db, m.sourceId).title, body: m.body };
408
+ pi.sendMessage({ customType: MESSAGE, content: frame(db, m), display: true, details }, ctx.isIdle() ? { triggerTurn: true } : { deliverAs: "steer" });
409
+ }
410
+ markDelivered(db, m.id);
411
+ }
412
+ } catch (error) {
413
+ fail(error);
414
+ }
415
+ };
416
+ pi.registerMessageRenderer(MESSAGE, (message, _options, theme) => {
417
+ const details = message.details;
418
+ return new Text(`${theme.fg("accent", `↘ ${details.title}`)}
419
+ ${details.body}`, 0, 0);
420
+ });
246
421
  pi.on("session_start", (event, ctx) => {
247
422
  current = ctx.sessionManager.getSessionId();
423
+ ctxNow = ctx;
424
+ seenVersion = -1;
248
425
  if (event.reason !== "reload")
249
426
  restoreDraft(ctx, current);
250
427
  proc.attention.clear();
428
+ if (proc.parent) {
429
+ launchedBy = { sessionId: current, parent: proc.parent };
430
+ if (!ctx.sessionManager.getEntries().some((entry) => entry.type === "custom" && entry.customType === LINEAGE))
431
+ pi.appendEntry(LINEAGE, { parentSessionId: proc.parent });
432
+ delete proc.parent;
433
+ }
251
434
  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
435
  const transcript = ctx.sessionManager.getSessionFile();
260
436
  if (!transcript)
261
437
  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);
438
+ startHosting(db, {
439
+ host: proc.launch.tmuxSession,
440
+ harness: "pi",
441
+ sessionId: id,
442
+ cwd: ctx.cwd,
443
+ transcript,
444
+ pid: process.pid,
445
+ idle: ctx.isIdle()
446
+ });
263
447
  });
448
+ if (proc.inbox)
449
+ clearInterval(proc.inbox);
450
+ proc.inbox = setInterval(deliver, 250);
451
+ proc.inbox.unref();
264
452
  });
265
453
  pi.on("agent_start", (_event, ctx) => {
266
454
  writeFor(ctx, (db, id) => {
267
455
  const transcript = ctx.sessionManager.getSessionFile();
268
456
  if (!transcript)
269
457
  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);
458
+ const title = ctx.sessionManager.getSessionName() ?? null;
459
+ const parentId = launchedBy?.sessionId === id ? launchedBy.parent : null;
460
+ startTurn(db, { harness: "pi", sessionId: id, cwd: ctx.cwd, transcript, title, parentId });
275
461
  });
276
462
  });
277
463
  const attention = (open) => (data) => {
@@ -283,27 +469,30 @@ function src_default(pi) {
283
469
  else
284
470
  proc.attention.delete(attentionId);
285
471
  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));
472
+ write(current, (db, id) => setPhase(db, id, phase));
287
473
  };
288
474
  pi.events.on("glimpseui:attention:request", attention(true));
289
475
  pi.events.on("glimpseui:attention:resolve", attention(false));
290
476
  pi.on("agent_settled", (_event, ctx) => {
291
477
  proc.attention.clear();
292
478
  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);
479
+ const { archived } = settle(db, {
480
+ host: proc.launch.tmuxSession,
481
+ sessionId: id,
482
+ cwd: ctx.cwd,
483
+ title: ctx.sessionManager.getSessionName() ?? null
484
+ });
485
+ if (archived)
486
+ ctx.shutdown();
295
487
  });
296
488
  });
297
489
  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));
490
+ writeFor(ctx, (db, id) => setTitle(db, id, event.name ?? null));
299
491
  });
300
- const markArchived = (ctx) => {
492
+ const markArchived2 = (ctx) => {
301
493
  let archived = false;
302
494
  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;
495
+ archived = recordTx(db, () => markArchived(db, id));
307
496
  });
308
497
  return archived;
309
498
  };
@@ -314,58 +503,77 @@ function src_default(pi) {
314
503
  ctx.ui.notify("swb: turn running: wait for it to complete", "error");
315
504
  return;
316
505
  }
317
- if (markArchived(ctx))
506
+ if (markArchived2(ctx))
318
507
  ctx.shutdown();
319
508
  else if (!proc.failure)
320
509
  ctx.ui.notify("swb: nothing to archive before the first prompt", "error");
321
510
  }
322
511
  });
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
- }),
512
+ const tool = (name, label, target, run) => pi.registerTool({
513
+ name,
514
+ label,
515
+ description: TOOLS[name].description,
516
+ promptSnippet: TOOLS[name].description,
517
+ promptGuidelines: TOOLS[name].guidelines,
518
+ parameters: TOOLS[name].parameters,
337
519
  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);
520
+ const what = target(args);
521
+ return new Text(`${theme.fg("toolTitle", theme.bold(label))}${what ? ` · ${theme.fg("accent", what)}` : ""}`, 0, 0);
340
522
  },
341
523
  renderResult(result, _options, theme, context) {
342
524
  const output = result.content.find((item) => item.type === "text")?.text ?? "";
343
- return new Text(context.isError ? theme.fg("error", output) : "", 0, 0);
525
+ return new Text(context.isError ? theme.fg("error", output) : theme.fg("dim", output), 0, 0);
344
526
  },
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
- };
527
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
528
+ if (proc.failure)
529
+ throw new Error(`swb: not recording: ${proc.failure}`);
530
+ const result = await run(params, ctx, toolCallId);
531
+ return { content: [{ type: "text", text: result.text }], details: result.details };
532
+ }
533
+ });
534
+ const self = (ctx) => ctx.sessionManager.getSessionId();
535
+ const titleOf = (id) => typeof id === "string" ? connect().get("SELECT title FROM sessions WHERE session_id = ?", id)?.title ?? id : "";
536
+ tool("session_reachable", "Reachable sessions", () => "", (_args, ctx) => swb(proc.launch.bin, ["reachable", "--from", self(ctx)]));
537
+ tool("session_send_message", "Message", (args) => titleOf(args.session), (args, ctx, toolCallId) => swb(proc.launch.bin, [
538
+ "send",
539
+ "--from",
540
+ self(ctx),
541
+ "--to",
542
+ args.session,
543
+ "--request-id",
544
+ toolCallId,
545
+ ...args.requestResponse ? ["--request-response"] : []
546
+ ], args.message));
547
+ tool("session_cancel", "Cancel", (args) => titleOf(args.session), (args, ctx) => swb(proc.launch.bin, ["send", "--from", self(ctx), "--to", args.session, "--cancel"]));
548
+ tool("session_launch", "Launch", (args) => `${args.harness} · ${args.title}`, (args, ctx) => swb(proc.launch.bin, [
549
+ "launch",
550
+ "--harness",
551
+ args.harness,
552
+ "--from",
553
+ self(ctx),
554
+ "--cwd",
555
+ args.cwd ?? ctx.cwd,
556
+ "--title",
557
+ args.title,
558
+ ...args.requestResponse ? ["--request-response"] : []
559
+ ], args.goal));
560
+ tool("swb_update_session", "Update session", (args) => `${args.archived ? "archive" : "unarchive"} ${args.session ? titleOf(args.session) : "this session"}`, async (args, ctx) => {
561
+ const id = self(ctx);
562
+ if (!args.session || args.session === id) {
563
+ if (!args.archived)
564
+ throw new Error("this session is already open");
565
+ if (!markArchived2(ctx))
566
+ throw new Error(proc.failure ? `swb: not recording: ${proc.failure}` : "swb: no session recorded yet");
567
+ ctx.shutdown();
568
+ return { text: "Archived. pi quits when this turn ends.", details: { id, archived: true } };
366
569
  }
570
+ return swb(proc.launch.bin, [args.archived ? "archive" : "unarchive", args.session, "--from", id]);
367
571
  });
368
572
  pi.on("session_shutdown", (event, ctx) => {
573
+ if (proc.inbox)
574
+ clearInterval(proc.inbox);
575
+ proc.inbox = undefined;
576
+ ctxNow = undefined;
369
577
  if (event.reason !== "quit")
370
578
  return;
371
579
  const draft = ctx.ui.getEditorText();
@@ -374,7 +582,7 @@ function src_default(pi) {
374
582
  mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
375
583
  writeFileSync(path, draft, { mode: 384 });
376
584
  }
377
- writeFor(ctx, (db) => db.run("DELETE FROM runtimes WHERE tmux_session = ?", proc.launch.tmuxSession));
585
+ writeFor(ctx, (db) => endHosting(db, proc.launch.tmuxSession));
378
586
  });
379
587
  }
380
588
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thurstonsand/pi-agent-switchboard",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Records managed pi sessions for Agent Switchboard (swb)",
6
6
  "license": "MIT",