@trim21/personal-pi-extensions 0.0.208 → 0.0.210

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/src/talk/index.ts CHANGED
@@ -16,13 +16,14 @@ import { fileURLToPath } from "node:url";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
18
  import { Box, Text } from "@earendil-works/pi-tui";
19
- import { Type } from "typebox";
19
+ import { type TObject, Type } from "typebox";
20
20
 
21
+ import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
21
22
  import { resolveHomePath } from "../lib/path.js";
22
23
  import { TalkCore } from "./core.js";
23
24
  import { formatDelivery } from "./format.js";
24
25
  import type { Letter } from "./mailbox.js";
25
- import { deriveAddr, type SessionRecord } from "./registry.js";
26
+ import { type AgentRecord, deriveAddr } from "./registry.js";
26
27
  import { SqliteTalkStorage } from "./storage.js";
27
28
 
28
29
  const DELIVERY_TYPE = "talk:delivery";
@@ -108,14 +109,16 @@ export default function talk(pi: ExtensionAPI) {
108
109
  const dbPath = configured
109
110
  ? resolveHomePath(configured, getAgentDir())
110
111
  : path.join(getAgentDir(), "talk.db");
111
- // "queue": deliver on the session's next natural turn without waking it;
112
- // "steer": interrupt mid-run / wake an idle session immediately.
112
+ // "queue": deliver on the agent's next natural turn without waking it;
113
+ // "steer": interrupt mid-run / wake an idle agent immediately.
113
114
  const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
114
115
  const storage = new SqliteTalkStorage(dbPath);
115
116
 
116
- let self: SessionRecord | undefined;
117
+ let self: AgentRecord | undefined;
118
+ /** Display name explicitly set via `--name`; kept across session_info_changed. */
119
+ let explicitName: string | undefined;
117
120
 
118
- function deliverToSession(letter: Letter): boolean {
121
+ function deliverToAgent(letter: Letter): boolean {
119
122
  const details: DeliveryDetails = {
120
123
  id: letter.id,
121
124
  kind: letter.kind,
@@ -142,7 +145,7 @@ export default function talk(pi: ExtensionAPI) {
142
145
  const core = new TalkCore({
143
146
  storage,
144
147
  events: {
145
- deliver: deliverToSession,
148
+ deliver: deliverToAgent,
146
149
  notify(content) {
147
150
  // Presence transitions are informational — queue for the next turn
148
151
  // rather than steering into a busy agent.
@@ -162,13 +165,13 @@ export default function talk(pi: ExtensionAPI) {
162
165
  // ── Lifecycle ──────────────────────────────────────────────────────────
163
166
 
164
167
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
165
- const sessionId = ctx.sessionManager.getSessionId();
168
+ const agentId = ctx.sessionManager.getSessionId();
166
169
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
167
170
  const now = Date.now();
168
171
  self = {
169
- addr: deriveAddr(cwd, sessionId),
170
- sessionId,
171
- name: pi.getSessionName() ?? "Unnamed session",
172
+ addr: deriveAddr(cwd, agentId),
173
+ agentId,
174
+ name: pi.getSessionName() ?? "Unnamed agent",
172
175
  cwd,
173
176
  pid: process.pid,
174
177
  startedAt: now,
@@ -182,15 +185,17 @@ export default function talk(pi: ExtensionAPI) {
182
185
  pi.on("agent_end", () => core.setIdle());
183
186
  pi.on("agent_settled", () => core.setIdle());
184
187
  pi.on("before_agent_start", (event) => {
185
- // One-line nudge: before coordinating with other pi sessions, read the
188
+ // One-line nudge: before coordinating with other pi agents, read the
186
189
  // shipped workflow skill. Skipped when the skill file is absent.
187
190
  if (!fs.existsSync(SKILL_PATH)) return;
188
191
  return {
189
- systemPrompt: `${event.systemPrompt}\n\nBefore multi-session collaboration, read ${SKILL_PATH} to understand the talk workflow.`,
192
+ systemPrompt: `${event.systemPrompt}\n\nBefore multi-agent collaboration, read ${SKILL_PATH} to understand the talk workflow.`,
190
193
  };
191
194
  });
192
195
  pi.on("session_info_changed", () => {
193
- if (self) core.setSessionName(pi.getSessionName() ?? self.name);
196
+ // A name set explicitly via `--name` wins over pi's session title;
197
+ // otherwise follow pi's session name.
198
+ if (self) core.setAgentName(explicitName ?? pi.getSessionName() ?? self.name);
194
199
  });
195
200
  pi.on("session_shutdown", () => {
196
201
  void core.stop();
@@ -199,12 +204,12 @@ export default function talk(pi: ExtensionAPI) {
199
204
  // ── Tools ──────────────────────────────────────────────────────────────
200
205
 
201
206
  pi.registerTool({
202
- name: "talk-list-sessions",
203
- label: "List Talk Sessions",
204
- description: "List visible pi sessions (id, status, work_dir, name).",
205
- promptSnippet: "List other pi sessions on this machine",
207
+ name: "talk-list-agents",
208
+ label: "List Talk Agents",
209
+ description: "List visible pi agents (id, status, work_dir, name).",
210
+ promptSnippet: "List other pi agents on this machine",
206
211
  parameters: Type.Object({
207
- cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
212
+ cwd: Type.Optional(Type.String({ description: "Only list agents in this directory" })),
208
213
  }),
209
214
  async execute(_toolCallId, params) {
210
215
  const initError = requireInit();
@@ -217,10 +222,10 @@ export default function talk(pi: ExtensionAPI) {
217
222
  name: "talk-ask",
218
223
  label: "Ask Talk",
219
224
  description:
220
- "Ask another pi session a question and block until it replies (or times out). Before asking, it checks whether that session already sent you something; if so, you are told to read and reply first instead of asking.",
221
- promptSnippet: "Ask another pi session a question and wait for the reply",
225
+ "Ask another pi agent a question and block until it replies (or times out). Before asking, it checks whether that agent already sent you something; if so, you are told to read and reply first instead of asking.",
226
+ promptSnippet: "Ask another pi agent a question and wait for the reply",
222
227
  parameters: Type.Object({
223
- to: Type.String({ description: "Target session (name/address/@alias)" }),
228
+ to: Type.String({ description: "Target agent (name/address/@alias)" }),
224
229
  message: Type.String({ description: "The question" }),
225
230
  timeoutMs: Type.Optional(
226
231
  Type.Number({ description: `Wait cap in ms; default ${ASK_TIMEOUT_MS}` }),
@@ -244,10 +249,10 @@ export default function talk(pi: ExtensionAPI) {
244
249
  name: "talk-send",
245
250
  label: "Send Talk Message",
246
251
  description:
247
- "Send a plain-text message to a single pi session. Plain text only, ≤32KB — send a summary and a path, never file contents.",
248
- promptSnippet: "Send a message to another pi session",
252
+ "Send a plain-text message to a single pi agent. Plain text only, ≤32KB — send a summary and a path, never file contents.",
253
+ promptSnippet: "Send a message to another pi agent",
249
254
  parameters: Type.Object({
250
- to: Type.String({ description: "Target session id (from talk-list-sessions)" }),
255
+ to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
251
256
  message: Type.String({ description: "Message body" }),
252
257
  }),
253
258
  async execute(_toolCallId, params) {
@@ -276,88 +281,155 @@ export default function talk(pi: ExtensionAPI) {
276
281
 
277
282
  // ── /talk commands ────────────────────────────────────────────────────
278
283
 
279
- pi.registerCommand("talk", {
280
- description: "List registered pi sessions",
281
- async handler() {
282
- const text = requireInit() ?? (await core.list());
284
+ type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
285
+
286
+ /** Parse a /talk command; on help/error or init failure the text is sent, otherwise run() produces the listing text. */
287
+ function handleCommand<TFlags extends TObject>(
288
+ spec: CommandSpec<TFlags>,
289
+ args: string,
290
+ run: (parsed: OkResult<TFlags>) => Promise<string> | string,
291
+ ): Promise<void> {
292
+ const parsed = parseCommand(spec, args);
293
+ if (parsed.kind !== "ok") {
294
+ pi.sendMessage({ customType: LIST_TYPE, content: parsed.text, display: true });
295
+ return Promise.resolve();
296
+ }
297
+ return (async () => {
298
+ const initError = requireInit();
299
+ const text = initError ?? (await run(parsed));
283
300
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
284
- },
301
+ })();
302
+ }
303
+
304
+ const TALK_SPEC = {
305
+ name: "talk",
306
+ usage: "",
307
+ description: "List registered pi agents",
308
+ flags: Type.Object({}),
309
+ arity: { max: 0 },
310
+ };
311
+
312
+ const TALK_DEAD_SPEC = {
313
+ name: "talk-dead",
314
+ usage: "[agentId] [options]",
315
+ description:
316
+ "Mark a talk agent as dead (shown offline, swept soon): no arg = this agent, <agentId> = that agent, --all = every other visible agent",
317
+ flags: Type.Object({
318
+ all: Type.Optional(Type.Boolean({ description: "Mark every other visible agent dead" })),
319
+ }),
320
+ flagMeta: { all: { short: "a" } },
321
+ arity: { max: 1 },
322
+ };
323
+
324
+ const TALK_GROUP_JOIN_SPEC = {
325
+ name: "talk-group-join",
326
+ usage: "[group name] [options]",
327
+ description:
328
+ "Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name",
329
+ flags: Type.Object({
330
+ name: Type.Optional(Type.String({ description: "Set this agent's display name" })),
331
+ }),
332
+ flagMeta: { name: { short: "n", valuePlaceholder: "<alias>" } },
333
+ arity: { max: 1 },
334
+ examples: ["/talk-group-join frontend", "/talk-group-join --name frontend"],
335
+ };
336
+
337
+ const TALK_GROUP_JOIN_LAST_SPEC = {
338
+ name: "talk-group-join-last",
339
+ usage: "",
340
+ description: "Join the most recently created agent group (no-op when already in it).",
341
+ flags: Type.Object({}),
342
+ arity: { max: 0 },
343
+ };
344
+
345
+ const TALK_GROUP_LEAVE_SPEC = {
346
+ name: "talk-group-leave",
347
+ usage: "",
348
+ description: "Leave the current agent group (an emptied group is deleted).",
349
+ flags: Type.Object({}),
350
+ arity: { max: 0 },
351
+ };
352
+
353
+ const TALK_GROUP_LIST_SPEC = {
354
+ name: "talk-group-list",
355
+ usage: "",
356
+ description: "List all agent groups and their members, newest first.",
357
+ flags: Type.Object({}),
358
+ arity: { max: 0 },
359
+ };
360
+
361
+ const TALK_GROUP_DEL_SPEC = {
362
+ name: "talk-group-del",
363
+ usage: "<group name>",
364
+ description:
365
+ "Delete an agent group by name; its members become ungrouped (see only themselves).",
366
+ flags: Type.Object({}),
367
+ arity: { min: 1, max: 1 },
368
+ };
369
+
370
+ const TALK_GROUP_CLEAR_SPEC = {
371
+ name: "talk-group-clear",
372
+ usage: "",
373
+ description: "Delete every agent group; all agents become ungrouped.",
374
+ flags: Type.Object({}),
375
+ arity: { max: 0 },
376
+ };
377
+
378
+ pi.registerCommand("talk", {
379
+ description: TALK_SPEC.description,
380
+ handler: (args) => handleCommand(TALK_SPEC, args, async () => core.list()),
285
381
  });
286
382
 
287
383
  pi.registerCommand("talk-dead", {
288
- description:
289
- "Mark a talk session as dead (shown offline, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
290
- async handler(args) {
291
- const initError = requireInit();
292
- const trimmed = args.trim();
293
- const text =
294
- initError ??
295
- (trimmed === "--all"
384
+ description: TALK_DEAD_SPEC.description,
385
+ handler: (args) =>
386
+ handleCommand(TALK_DEAD_SPEC, args, async (parsed) => {
387
+ if (parsed.flags.all && parsed.args.length > 0) {
388
+ return "--all cannot be combined with an agent id.\nTry '/talk-dead --help' for usage.";
389
+ }
390
+ return parsed.flags.all
296
391
  ? await core.markAllDead()
297
- : trimmed
298
- ? await core.markDead(trimmed)
299
- : await core.markDead());
300
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
301
- },
392
+ : parsed.args[0]
393
+ ? await core.markDead(parsed.args[0])
394
+ : await core.markDead();
395
+ }),
302
396
  });
303
397
 
304
398
  pi.registerCommand("talk-group-join", {
305
- description:
306
- "Join or create a private session group (members see only each other; a session in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist",
307
- async handler(args) {
308
- const initError = requireInit();
309
- const token = args.trim();
310
- const text = initError ?? (await core.groupJoin(token || undefined));
311
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
312
- },
399
+ description: TALK_GROUP_JOIN_SPEC.description,
400
+ handler: (args) =>
401
+ handleCommand(TALK_GROUP_JOIN_SPEC, args, async (parsed) => {
402
+ const agentName = parsed.flags.name?.trim() || undefined;
403
+ if (agentName !== undefined) explicitName = agentName;
404
+ return core.groupJoin(parsed.args[0], agentName);
405
+ }),
313
406
  });
314
407
 
315
408
  pi.registerCommand("talk-group-join-last", {
316
- description: "Join the most recently created session group (no-op when already in it).",
317
- async handler() {
318
- const initError = requireInit();
319
- const text = initError ?? (await core.groupJoinLast());
320
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
321
- },
409
+ description: TALK_GROUP_JOIN_LAST_SPEC.description,
410
+ handler: (args) =>
411
+ handleCommand(TALK_GROUP_JOIN_LAST_SPEC, args, async () => core.groupJoinLast()),
322
412
  });
323
413
 
324
414
  pi.registerCommand("talk-group-leave", {
325
- description: "Leave the current session group (an emptied group is deleted).",
326
- async handler() {
327
- const initError = requireInit();
328
- const text = initError ?? (await core.groupLeave());
329
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
330
- },
415
+ description: TALK_GROUP_LEAVE_SPEC.description,
416
+ handler: (args) => handleCommand(TALK_GROUP_LEAVE_SPEC, args, async () => core.groupLeave()),
331
417
  });
332
418
 
333
419
  pi.registerCommand("talk-group-list", {
334
- description: "List all session groups and their members, newest first.",
335
- async handler() {
336
- const initError = requireInit();
337
- const text = initError ?? (await core.groupList());
338
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
339
- },
420
+ description: TALK_GROUP_LIST_SPEC.description,
421
+ handler: (args) => handleCommand(TALK_GROUP_LIST_SPEC, args, async () => core.groupList()),
340
422
  });
341
423
 
342
424
  pi.registerCommand("talk-group-del", {
343
- description:
344
- "Delete a session group by name; its members become ungrouped (see only themselves).",
345
- async handler(args) {
346
- const initError = requireInit();
347
- const name = args.trim();
348
- const text =
349
- initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
350
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
351
- },
425
+ description: TALK_GROUP_DEL_SPEC.description,
426
+ handler: (args) =>
427
+ handleCommand(TALK_GROUP_DEL_SPEC, args, async (parsed) => core.groupDelete(parsed.args[0])),
352
428
  });
353
429
 
354
430
  pi.registerCommand("talk-group-clear", {
355
- description: "Delete every session group; all sessions become ungrouped.",
356
- async handler() {
357
- const initError = requireInit();
358
- const text = initError ?? (await core.groupClear());
359
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
360
- },
431
+ description: TALK_GROUP_CLEAR_SPEC.description,
432
+ handler: (args) => handleCommand(TALK_GROUP_CLEAR_SPEC, args, async () => core.groupClear()),
361
433
  });
362
434
 
363
435
  // ── Delivery card ──────────────────────────────────────────────────────
@@ -6,7 +6,7 @@
6
6
  * - A reader never sees half a letter: writes are atomic (single SQL upsert).
7
7
  * - Consumption is decoupled from delivery: `listInbox` only reads; the
8
8
  * caller removes a letter with `removeLetter` AFTER it has been handed to
9
- * the session. A letter that could not be delivered stays in the inbox and
9
+ * the agent. A letter that could not be delivered stays in the inbox and
10
10
  * is retried on the next poll.
11
11
  * - Every value read from storage is validated with a TypeBox schema.
12
12
  * - Every deposit and delivery appends one append-only audit line. The log
@@ -27,7 +27,7 @@ export const LetterSchema = Type.Object({
27
27
  addr: Type.String(),
28
28
  name: Type.String(),
29
29
  cwd: Type.String(),
30
- sessionId: Type.String(),
30
+ agentId: Type.String(),
31
31
  }),
32
32
  kind: Type.Union([
33
33
  Type.Literal("message"),
@@ -42,6 +42,25 @@ export const LetterSchema = Type.Object({
42
42
  export type Letter = Static<typeof LetterSchema>;
43
43
  export type LetterKind = Letter["kind"];
44
44
 
45
+ /**
46
+ * Migrate letters written before the session→agent rename, which carried the
47
+ * sender's pi session id as `from.sessionId`. Current letters pass through
48
+ * unchanged; anything else returns null.
49
+ */
50
+ export function normalizeLetter(value: unknown): Letter | null {
51
+ if (Value.Check(LetterSchema, value)) return value;
52
+ const record = value as { from?: unknown } | null;
53
+ const from = record?.from;
54
+ if (typeof from !== "object" || from === null) return null;
55
+ const fromRecord = from as Record<string, unknown>;
56
+ if (typeof fromRecord.sessionId !== "string") return null;
57
+ const migrated = {
58
+ ...(value as object),
59
+ from: { ...fromRecord, agentId: fromRecord.sessionId },
60
+ };
61
+ return Value.Check(LetterSchema, migrated) ? migrated : null;
62
+ }
63
+
45
64
  export const OutAskSchema = Type.Object({
46
65
  askId: Type.String(),
47
66
  toAddr: Type.String(),
@@ -133,7 +152,8 @@ export async function listInbox(storage: TalkStorage, addr: string): Promise<Inb
133
152
  const out: InboxItem[] = [];
134
153
  for (const fileName of await storage.listKeys(inboxNs(addr))) {
135
154
  const raw = await storage.readJson(inboxNs(addr), fileName);
136
- if (isValidLetter(raw)) out.push({ fileName, letter: raw });
155
+ const letter = normalizeLetter(raw);
156
+ if (letter && isValidLetter(letter)) out.push({ fileName, letter });
137
157
  // corrupt letters are skipped; the caller may remove them separately
138
158
  }
139
159
  return out; // keys are already sorted by listKeys
@@ -161,7 +181,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
161
181
  * Consumption receipt: after depositing to a LIVE target, wait briefly for
162
182
  * the exact letter to vanish from the target's inbox. Under the
163
183
  * deliver-then-remove semantics, disappearance means the receiver actually
164
- * handed the letter to its session, not merely drained it.
184
+ * handed the letter to its agent, not merely drained it.
165
185
  */
166
186
  export async function awaitReceipt(
167
187
  storage: TalkStorage,
@@ -218,7 +238,7 @@ export async function readIncomingAsk(
218
238
  askId: string,
219
239
  ): Promise<Letter | null> {
220
240
  const raw = await storage.readJson(asksNs(addr), askKey(askId));
221
- return Value.Check(LetterSchema, raw) ? raw : null;
241
+ return normalizeLetter(raw);
222
242
  }
223
243
 
224
244
  export async function readOutgoingAsk(
@@ -242,7 +262,8 @@ export async function pendingAsks(storage: TalkStorage, addr: string): Promise<L
242
262
  for (const key of await storage.listKeys(asksNs(addr))) {
243
263
  if (key.startsWith("out-")) continue;
244
264
  const raw = await storage.readJson(asksNs(addr), key);
245
- if (Value.Check(LetterSchema, raw)) out.push(raw);
265
+ const letter = normalizeLetter(raw);
266
+ if (letter) out.push(letter);
246
267
  }
247
268
  return out;
248
269
  }
@@ -57,7 +57,7 @@ export class OutboundPolicy {
57
57
  if (this.sentAt.length >= RATE_LIMIT_MAX) {
58
58
  return {
59
59
  ok: false,
60
- reason: `Rate limited: ${RATE_LIMIT_MAX} messages per ${RATE_LIMIT_WINDOW_MS / 1000}s per session.`,
60
+ reason: `Rate limited: ${RATE_LIMIT_MAX} messages per ${RATE_LIMIT_WINDOW_MS / 1000}s per agent.`,
61
61
  };
62
62
  }
63
63
  return { ok: true };
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Session registry for the talk mailbox: who is around, where, and whether
2
+ * Agent registry for the talk mailbox: who is around, where, and whether
3
3
  * they are reachable. Core layer — depends only on TalkStorage, never on pi.
4
4
  *
5
5
  * Design:
6
6
  * - An address belongs to a conversation, not a process: hash of cwd + pi
7
- * session id, so a resumed session (`pi -c`) answers to the same address and
8
- * two sessions on one directory never share an inbox.
9
- * - A record outlives the process that wrote it — that's what makes a session
7
+ * agent id, so a resumed agent (`pi -c`) answers to the same address and
8
+ * two agents on one directory never share an inbox.
9
+ * - A record outlives the process that wrote it — that's what makes an agent
10
10
  * addressable while it's down (mail waits on disk).
11
11
  * - Presence is the offline flag plus the pid and its start time: a record
12
12
  * whose process is alive (pid + matching start time, ruling out pid reuse)
@@ -26,7 +26,32 @@ import { Value } from "typebox/value";
26
26
 
27
27
  import type { TalkStorage } from "./storage.js";
28
28
 
29
- export const SessionRecordSchema = Type.Object({
29
+ const STATUS_SCHEMA = Type.Union([
30
+ Type.Literal("idle"),
31
+ Type.Literal("working"),
32
+ Type.Literal("waiting-talk-message"),
33
+ ]);
34
+
35
+ export const AgentRecordSchema = Type.Object({
36
+ addr: Type.String(),
37
+ agentId: Type.String(),
38
+ name: Type.String(),
39
+ cwd: Type.String(),
40
+ pid: Type.Number(),
41
+ pidStart: Type.Optional(Type.Number()),
42
+ startedAt: Type.Number(),
43
+ lastSeenAt: Type.Number(),
44
+ status: STATUS_SCHEMA,
45
+ offline: Type.Optional(Type.Boolean()),
46
+ });
47
+ export type AgentRecord = Static<typeof AgentRecordSchema>;
48
+
49
+ /**
50
+ * Records written before the session→agent rename stored the pi session id
51
+ * as `sessionId`. TypeBox ignores extra properties, so this schema also
52
+ * matches current records; the read path checks the current schema first.
53
+ */
54
+ const LegacyAgentRecordSchema = Type.Object({
30
55
  addr: Type.String(),
31
56
  sessionId: Type.String(),
32
57
  name: Type.String(),
@@ -35,14 +60,9 @@ export const SessionRecordSchema = Type.Object({
35
60
  pidStart: Type.Optional(Type.Number()),
36
61
  startedAt: Type.Number(),
37
62
  lastSeenAt: Type.Number(),
38
- status: Type.Union([
39
- Type.Literal("idle"),
40
- Type.Literal("working"),
41
- Type.Literal("waiting-talk-message"),
42
- ]),
63
+ status: STATUS_SCHEMA,
43
64
  offline: Type.Optional(Type.Boolean()),
44
65
  });
45
- export type SessionRecord = Static<typeof SessionRecordSchema>;
46
66
 
47
67
  export type Presence = "live" | "offline";
48
68
 
@@ -53,8 +73,8 @@ export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
53
73
 
54
74
  const ADDRESS_PATTERN = /^[a-f0-9]{12}$/;
55
75
 
56
- export function deriveAddr(cwd: string, sessionId: string): string {
57
- return createHash("sha256").update(`${cwd}${sessionId}`).digest("hex").slice(0, 12);
76
+ export function deriveAddr(cwd: string, agentId: string): string {
77
+ return createHash("sha256").update(`${cwd}${agentId}`).digest("hex").slice(0, 12);
58
78
  }
59
79
 
60
80
  /** Validate a talk address before it becomes a storage key. */
@@ -81,23 +101,26 @@ function recordKey(addr: string): string {
81
101
  return `${addr}.json`;
82
102
  }
83
103
 
84
- // ── Session records ──────────────────────────────────────────────────────
104
+ // ── Agent records ────────────────────────────────────────────────────────
85
105
 
86
- export async function writeRecord(storage: TalkStorage, record: SessionRecord): Promise<void> {
106
+ export async function writeRecord(storage: TalkStorage, record: AgentRecord): Promise<void> {
87
107
  await storage.writeJson(RECORDS_NS, recordKey(record.addr), record);
88
108
  }
89
109
 
90
- export async function readRecord(
91
- storage: TalkStorage,
92
- addr: string,
93
- ): Promise<SessionRecord | null> {
110
+ export async function readRecord(storage: TalkStorage, addr: string): Promise<AgentRecord | null> {
94
111
  const raw = await storage.readJson(RECORDS_NS, recordKey(addr));
95
- return Value.Check(SessionRecordSchema, raw) ? raw : null;
112
+ if (Value.Check(AgentRecordSchema, raw)) return raw;
113
+ // Migrate legacy records in place of the `sessionId` → `agentId` rename.
114
+ if (Value.Check(LegacyAgentRecordSchema, raw)) {
115
+ const { sessionId, ...rest } = raw as { sessionId: string } & Record<string, unknown>;
116
+ return { ...rest, agentId: sessionId } as AgentRecord;
117
+ }
118
+ return null;
96
119
  }
97
120
 
98
121
  /** Read-only listing, oldest first. Never mutates anything. */
99
- export async function listRecords(storage: TalkStorage): Promise<SessionRecord[]> {
100
- const out: SessionRecord[] = [];
122
+ export async function listRecords(storage: TalkStorage): Promise<AgentRecord[]> {
123
+ const out: AgentRecord[] = [];
101
124
  for (const key of await storage.listKeys(RECORDS_NS)) {
102
125
  const addr = key.slice(0, -".json".length);
103
126
  if (!ADDRESS_PATTERN.test(addr)) continue;
@@ -109,7 +132,7 @@ export async function listRecords(storage: TalkStorage): Promise<SessionRecord[]
109
132
 
110
133
  /**
111
134
  * Process start time (field 22 of /proc/<pid>/stat), used to rule out pid
112
- * reuse: pids wrap around, so an alive pid is only the same session when its
135
+ * reuse: pids wrap around, so an alive pid is only the same agent when its
113
136
  * start time matches the recorded one. Returns undefined on non-Linux or when
114
137
  * the stat file is unreadable.
115
138
  */
@@ -146,7 +169,7 @@ function pidAlive(pid: number, pidStart?: number): boolean {
146
169
  return start === undefined ? true : start === pidStart;
147
170
  }
148
171
 
149
- export function presenceOf(record: SessionRecord): Presence {
172
+ export function presenceOf(record: AgentRecord): Presence {
150
173
  if (record.offline) return "offline";
151
174
  if (!pidAlive(record.pid, record.pidStart)) return "offline";
152
175
  return "live";
@@ -155,20 +178,20 @@ export function presenceOf(record: SessionRecord): Presence {
155
178
  // ── Sweep ────────────────────────────────────────────────────────────────
156
179
 
157
180
  /**
158
- * Reclaim dead sessions' data. Rules (mail outranks tidiness):
181
+ * Reclaim dead agents' data. Rules (mail outranks tidiness):
159
182
  * - a record whose process is still alive is never touched;
160
183
  * - a record whose last activity was less than SWEEP_OFFLINE_GRACE_MS ago is
161
184
  * never touched — it may be merely down or suspended, and a resume will
162
185
  * re-register it under the same id anyway;
163
186
  * - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
164
187
  * - once the grace period has passed, an empty mailbox is discarded promptly
165
- * regardless of whether pi could still resume the session (resume re-creates
188
+ * regardless of whether pi could still resume the agent (resume re-creates
166
189
  * the record; with no mail nothing is lost).
167
190
  */
168
191
  export async function sweep(storage: TalkStorage, now: number = Date.now()): Promise<void> {
169
192
  for (const record of await listRecords(storage)) {
170
193
  // Without a heartbeat, lastSeenAt only tracks the last event, so an idle
171
- // live session would look long-quiet — never reap a live process.
194
+ // live agent would look long-quiet — never reap a live process.
172
195
  if (pidAlive(record.pid, record.pidStart)) continue;
173
196
  const quietFor = now - record.lastSeenAt;
174
197
  if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;