@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.
- package/index.js +327 -114
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
// packages/pi/src/index.ts
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
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/
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
495
|
+
writeFor(ctx, (db, id) => setTitle(db, id, event.name ?? null));
|
|
299
496
|
});
|
|
300
|
-
const
|
|
497
|
+
const markArchived2 = (ctx) => {
|
|
301
498
|
let archived = false;
|
|
302
499
|
writeFor(ctx, (db, id) => {
|
|
303
|
-
|
|
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 (
|
|
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
|
|
325
|
-
label
|
|
326
|
-
description:
|
|
327
|
-
promptSnippet:
|
|
328
|
-
promptGuidelines: [
|
|
329
|
-
|
|
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
|
|
339
|
-
return new Text(`${theme.fg("toolTitle", theme.bold(
|
|
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(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
590
|
+
writeFor(ctx, (db) => endHosting(db, proc.launch.tmuxSession));
|
|
378
591
|
});
|
|
379
592
|
}
|
|
380
593
|
export {
|