@thurstonsand/pi-agent-switchboard 0.5.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.
- package/index.js +322 -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,210 @@ 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
|
+
},
|
|
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.
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
|
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
|
|
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);
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
490
|
+
writeFor(ctx, (db, id) => setTitle(db, id, event.name ?? null));
|
|
299
491
|
});
|
|
300
|
-
const
|
|
492
|
+
const markArchived2 = (ctx) => {
|
|
301
493
|
let archived = false;
|
|
302
494
|
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;
|
|
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 (
|
|
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
|
|
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
|
-
}),
|
|
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
|
|
339
|
-
return new Text(`${theme.fg("toolTitle", theme.bold(
|
|
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(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
585
|
+
writeFor(ctx, (db) => endHosting(db, proc.launch.tmuxSession));
|
|
378
586
|
});
|
|
379
587
|
}
|
|
380
588
|
export {
|