@thurstonsand/pi-agent-switchboard 0.3.0 → 0.4.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 +79 -15
  2. package/package.json +2 -1
package/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // packages/pi/src/index.ts
2
2
  import { execFile } 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
5
  import { promisify } from "node:util";
6
+ import { Text } from "@earendil-works/pi-tui";
4
7
 
5
8
  // packages/shared/src/index.ts
6
9
  import { execFileSync } from "node:child_process";
7
- import { readFileSync } from "node:fs";
10
+ import { existsSync as existsSync2, readFileSync, realpathSync } from "node:fs";
11
+ import { homedir } from "node:os";
8
12
  import { dirname as dirname2, join } from "node:path";
9
13
 
10
14
  // packages/shared/src/db.ts
@@ -70,7 +74,20 @@ class Db {
70
74
  }
71
75
 
72
76
  // packages/shared/src/index.ts
73
- var SCHEMA_VERSION = 1;
77
+ var SCHEMA_VERSION = 3;
78
+ function configDir() {
79
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "agent-switchboard");
80
+ }
81
+ function operatorDir() {
82
+ if (physicalOperatorDir)
83
+ return physicalOperatorDir;
84
+ const dir = join(configDir(), "operator");
85
+ if (!existsSync2(dir))
86
+ return dir;
87
+ physicalOperatorDir = realpathSync(dir);
88
+ return physicalOperatorDir;
89
+ }
90
+ var physicalOperatorDir;
74
91
  function bootId() {
75
92
  if (process.platform === "darwin")
76
93
  return execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8" }).trim();
@@ -156,6 +173,7 @@ function hostOfTranscript(db, proc, transcript) {
156
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));
157
174
  }
158
175
  function src_default(pi) {
176
+ pi.on("project_trust", (event) => ({ trusted: event.cwd === operatorDir() ? "yes" : "undecided" }));
159
177
  const claimed = claim();
160
178
  if (!claimed)
161
179
  return;
@@ -211,8 +229,24 @@ function src_default(pi) {
211
229
  ctx.ui.notify(`swb: that session is already open in another pi (${host})`, "error");
212
230
  return { cancel: true };
213
231
  });
214
- pi.on("session_start", (_event, ctx) => {
232
+ const draftPath = (id) => join2(dirname3(proc.launch.db), "drafts", id);
233
+ const restoreDraft = (ctx, id) => {
234
+ let draft;
235
+ try {
236
+ draft = readFileSync2(draftPath(id), "utf8");
237
+ } catch (error) {
238
+ if (error.code === "ENOENT")
239
+ return;
240
+ throw error;
241
+ }
242
+ if (!ctx.ui.getEditorText())
243
+ ctx.ui.setEditorText(draft);
244
+ rmSync(draftPath(id));
245
+ };
246
+ pi.on("session_start", (event, ctx) => {
215
247
  current = ctx.sessionManager.getSessionId();
248
+ if (event.reason !== "reload")
249
+ restoreDraft(ctx, current);
216
250
  proc.attention.clear();
217
251
  writeFor(ctx, (db, id) => {
218
252
  const host = hostOfSession(db, proc, id);
@@ -287,22 +321,46 @@ function src_default(pi) {
287
321
  }
288
322
  });
289
323
  pi.registerTool({
290
- name: "swb_archive",
291
- label: "Archive session",
292
- description: "Archive this session in Agent Switchboard. It leaves the user's roster of open sessions, and pi quits as soon as this turn ends. The user can restore it with `swb unarchive <id>`.",
293
- promptSnippet: "Archive this session when the user asks",
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",
294
328
  promptGuidelines: [
295
- "Use swb_archive only when the user asks to archive this session; never archive on your own initiative.",
296
- "Call swb_archive as the last action of the turn, then end with a brief final message: pi quits once the turn ends."
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."
297
332
  ],
298
- parameters: Type.Object({}),
299
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
300
- if (!markArchived(ctx)) {
301
- throw new Error(proc.failure ? `swb: not recording: ${proc.failure}` : "swb: no session recorded yet");
333
+ parameters: Type.Object({
334
+ session: Type.Optional(Type.String({ description: "Session id; omit for the current session" })),
335
+ archived: Type.Boolean()
336
+ }),
337
+ 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);
340
+ },
341
+ renderResult(result, _options, theme, context) {
342
+ const output = result.content.find((item) => item.type === "text")?.text ?? "";
343
+ return new Text(context.isError ? theme.fg("error", output) : "", 0, 0);
344
+ },
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);
302
361
  }
303
- ctx.shutdown();
304
362
  return {
305
- content: [{ type: "text", text: "Archived. pi quits when this turn ends." }],
363
+ content: [{ type: "text", text: params.archived ? "Archived." : "Unarchived. It stays dormant until messaged." }],
306
364
  details: undefined
307
365
  };
308
366
  }
@@ -310,6 +368,12 @@ function src_default(pi) {
310
368
  pi.on("session_shutdown", (event, ctx) => {
311
369
  if (event.reason !== "quit")
312
370
  return;
371
+ const draft = ctx.ui.getEditorText();
372
+ if (draft.trim()) {
373
+ const path = draftPath(ctx.sessionManager.getSessionId());
374
+ mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
375
+ writeFileSync(path, draft, { mode: 384 });
376
+ }
313
377
  writeFor(ctx, (db) => db.run("DELETE FROM runtimes WHERE tmux_session = ?", proc.launch.tmuxSession));
314
378
  });
315
379
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thurstonsand/pi-agent-switchboard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Records managed pi sessions for Agent Switchboard (swb)",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@earendil-works/pi-coding-agent": "*",
21
+ "@earendil-works/pi-tui": "*",
21
22
  "typebox": "*"
22
23
  }
23
24
  }