@trim21/personal-pi-extensions 0.0.208 → 0.0.209

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.208",
3
+ "version": "0.0.209",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Minimal command-line argument parsing for `/command` handlers.
3
+ *
4
+ * The pi command API passes handlers a raw string (current versions) or a
5
+ * token array (newer ones); this helper accepts either and yields
6
+ * positionals plus flags, so handlers never care about the input shape.
7
+ *
8
+ * A raw string is split with shell-like rules first: whitespace separates
9
+ * tokens, single quotes preserve everything literally, double quotes allow
10
+ * `\"` / `\\` escapes, and a backslash outside quotes escapes the next
11
+ * character. Unlike bash, empty tokens are dropped and an unterminated
12
+ * quote raises a SyntaxError.
13
+ *
14
+ * Flags (only long form, `--name`):
15
+ * --name value flag "name" = "value" (value may not start with `--`)
16
+ * --name=value same
17
+ * --flag boolean flag = true
18
+ * -- everything after is a positional
19
+ */
20
+
21
+ export interface ParsedArgs {
22
+ /** Non-flag arguments, in order. */
23
+ positionals: string[];
24
+ /** `--name value` / `--name=value` → string; `--flag` → true. */
25
+ flags: Record<string, string | boolean>;
26
+ }
27
+
28
+ /** Does this token look like a long flag (`--x`, but not the bare `--`)? */
29
+ function isFlagToken(token: string): boolean {
30
+ return token.startsWith("--") && token !== "--";
31
+ }
32
+
33
+ /** Shell-like tokenizer for a raw command line. Empty tokens are dropped. */
34
+ export function shlexSplit(raw: string): string[] {
35
+ const tokens: string[] = [];
36
+ let cur = "";
37
+ let quote: "'" | '"' | undefined;
38
+ let i = 0;
39
+ while (i < raw.length) {
40
+ const c = raw[i];
41
+ if (quote) {
42
+ if (c === quote) {
43
+ quote = undefined;
44
+ } else if (c === "\\" && quote === '"' && (raw[i + 1] === '"' || raw[i + 1] === "\\")) {
45
+ cur += raw[i + 1];
46
+ i++;
47
+ } else {
48
+ cur += c;
49
+ }
50
+ i++;
51
+ continue;
52
+ }
53
+ if (c === "'" || c === '"') {
54
+ quote = c;
55
+ i++;
56
+ continue;
57
+ }
58
+ if (c === "\\") {
59
+ if (i + 1 < raw.length) cur += raw[i + 1];
60
+ i += 2;
61
+ continue;
62
+ }
63
+ if (/\s/.test(c)) {
64
+ if (cur) {
65
+ tokens.push(cur);
66
+ cur = "";
67
+ }
68
+ i++;
69
+ continue;
70
+ }
71
+ cur += c;
72
+ i++;
73
+ }
74
+ if (quote !== undefined) {
75
+ throw new SyntaxError(`unterminated quote in command arguments: ${raw}`);
76
+ }
77
+ if (cur) tokens.push(cur);
78
+ return tokens;
79
+ }
80
+
81
+ export function parseArgs(input: string | string[]): ParsedArgs {
82
+ const tokens = Array.isArray(input) ? input : shlexSplit(input);
83
+ const positionals: string[] = [];
84
+ const flags: Record<string, string | boolean> = {};
85
+ let positionalOnly = false;
86
+ for (let i = 0; i < tokens.length; i++) {
87
+ const token = tokens[i];
88
+ if (positionalOnly) {
89
+ positionals.push(token);
90
+ continue;
91
+ }
92
+ if (token === "--") {
93
+ positionalOnly = true;
94
+ continue;
95
+ }
96
+ const m = /^--([a-zA-Z0-9][a-zA-Z0-9-]*)(?:=(.*))?$/.exec(token);
97
+ if (!m) {
98
+ positionals.push(token);
99
+ continue;
100
+ }
101
+ const name = m[1];
102
+ if (m[2] !== undefined) {
103
+ flags[name] = m[2];
104
+ continue;
105
+ }
106
+ // `--name value` form: consume the next token unless it is itself a flag.
107
+ const next = tokens[i + 1];
108
+ if (next !== undefined && !isFlagToken(next)) {
109
+ flags[name] = next;
110
+ i++;
111
+ } else {
112
+ flags[name] = true;
113
+ }
114
+ }
115
+ return { positionals, flags };
116
+ }
package/src/talk/core.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  * storage backend, and yields deliveries and notifications to an adapter
4
4
  * through events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
5
5
  *
6
- * Visibility model: groups are the only visibility boundary. A session in a
7
- * group sees only its co-members; a session in no group sees only itself.
6
+ * Visibility model: groups are the only visibility boundary. An agent in a
7
+ * group sees only its co-members; an agent in no group sees only itself.
8
8
  * Membership is read live from storage on every operation, so joining or
9
- * leaving a group takes effect immediately for every session.
9
+ * leaving a group takes effect immediately for every agent.
10
10
  *
11
11
  * Delivery model: a letter is removed from the inbox only AFTER the adapter
12
- * reports it was handed to the session (`events.deliver` returns true). A
12
+ * reports it was handed to the agent (`events.deliver` returns true). A
13
13
  * letter whose delivery fails stays in the inbox and is retried on the next
14
14
  * poll — so a swallowed sendMessage error no longer destroys the letter.
15
15
  */
@@ -17,7 +17,7 @@
17
17
  import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
18
18
  import {
19
19
  deleteGroup,
20
- groupForSession,
20
+ groupForAgent,
21
21
  isValidGroupName,
22
22
  listGroups,
23
23
  newGroupId,
@@ -45,12 +45,12 @@ import {
45
45
  } from "./mailbox.js";
46
46
  import { inboundAccepts, OutboundPolicy } from "./policy.js";
47
47
  import {
48
+ type AgentRecord,
48
49
  listRecords,
49
50
  type Presence,
50
51
  presenceOf,
51
52
  readRecord,
52
53
  readStartTime,
53
- type SessionRecord,
54
54
  sweep,
55
55
  writeRecord,
56
56
  } from "./registry.js";
@@ -58,7 +58,7 @@ import type { TalkStorage } from "./storage.js";
58
58
 
59
59
  type AskOutcome =
60
60
  { replied: true; body: string; from: string } | { replied: false; reason: string };
61
- type TargetResult = { ok: true; record: SessionRecord } | { ok: false; error: string };
61
+ type TargetResult = { ok: true; record: AgentRecord } | { ok: false; error: string };
62
62
  type SendResult = { ok: true; letter: Letter; verdict: string } | { ok: false; error: string };
63
63
 
64
64
  export interface TalkCoreEvents {
@@ -84,14 +84,14 @@ const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
84
84
  * Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
85
85
  * the two ask letters are fixed values inside the letters, so both sides
86
86
  * compare the same pair and reach symmetric conclusions. On a same-ms
87
- * collision, `cwd + sessionId` breaks the tie deterministically.
87
+ * collision, `cwd + agentId` breaks the tie deterministically.
88
88
  */
89
89
  export function peerAskedFirst(
90
- peer: { ts: number; cwd: string; sessionId: string },
91
- self: { ts: number; cwd: string; sessionId: string },
90
+ peer: { ts: number; cwd: string; agentId: string },
91
+ self: { ts: number; cwd: string; agentId: string },
92
92
  ): boolean {
93
- const peerKey = `${peer.cwd}\u0000${peer.sessionId}`;
94
- const selfKey = `${self.cwd}\u0000${self.sessionId}`;
93
+ const peerKey = `${peer.cwd}\u0000${peer.agentId}`;
94
+ const selfKey = `${self.cwd}\u0000${self.agentId}`;
95
95
  return peer.ts < self.ts || (peer.ts === self.ts && peerKey < selfKey);
96
96
  }
97
97
 
@@ -100,7 +100,7 @@ export class TalkCore {
100
100
  private readonly events: TalkCoreEvents;
101
101
  private readonly now: () => number;
102
102
 
103
- private self: SessionRecord | undefined;
103
+ private self: AgentRecord | undefined;
104
104
  private readonly policy = new OutboundPolicy();
105
105
  private readonly askWaiters = new Map<string, (outcome: AskOutcome) => void>();
106
106
  private readonly watched = new Map<string, Presence>();
@@ -124,7 +124,7 @@ export class TalkCore {
124
124
  return this.self?.addr;
125
125
  }
126
126
 
127
- private requireSelf(): SessionRecord {
127
+ private requireSelf(): AgentRecord {
128
128
  const self = this.self;
129
129
  if (!self) throw new Error("Talk core is not started");
130
130
  return self;
@@ -132,7 +132,7 @@ export class TalkCore {
132
132
 
133
133
  // ── Lifecycle ──────────────────────────────────────────────────────────
134
134
 
135
- async start(self: SessionRecord): Promise<void> {
135
+ async start(self: AgentRecord): Promise<void> {
136
136
  await this.storage.init();
137
137
  // Record the process start time so presence can rule out pid reuse later.
138
138
  const pidStart = readStartTime(self.pid);
@@ -141,7 +141,7 @@ export class TalkCore {
141
141
  try {
142
142
  await sweep(this.storage, this.now());
143
143
  } catch {
144
- // sweep failure never breaks the session
144
+ // sweep failure never breaks the agent
145
145
  }
146
146
  this.startInboxPoll();
147
147
  // Reclaim dead records periodically, not just at startup.
@@ -150,7 +150,7 @@ export class TalkCore {
150
150
  }, SWEEP_INTERVAL_MS);
151
151
  this.sweeper.unref();
152
152
  // Drain mail queued while offline — deferred: delivering during
153
- // session_start races the session's own first turn.
153
+ // session_start races the agent's own first turn.
154
154
  const initial = setTimeout(() => {
155
155
  void this.checkInbox();
156
156
  }, INITIAL_DRAIN_DELAY_MS);
@@ -183,18 +183,18 @@ export class TalkCore {
183
183
  void this.writeSelf({ status: "idle" });
184
184
  }
185
185
 
186
- setSessionName(name: string): void {
186
+ setAgentName(name: string): void {
187
187
  void this.writeSelf({ name });
188
188
  }
189
189
 
190
- private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
190
+ private async writeSelf(patch: Partial<AgentRecord>): Promise<void> {
191
191
  if (!this.self) return;
192
- // A dead session pins lastSeenAt to 0 so no later event re-freshens it.
192
+ // A dead agent pins lastSeenAt to 0 so no later event re-freshens it.
193
193
  this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
194
194
  try {
195
195
  await writeRecord(this.storage, this.self);
196
196
  } catch {
197
- // registration failures never break the session
197
+ // registration failures never break the agent
198
198
  }
199
199
  }
200
200
 
@@ -297,32 +297,32 @@ export class TalkCore {
297
297
  // ── Outbound ───────────────────────────────────────────────────────────
298
298
 
299
299
  /**
300
- * Session ids of the caller's group members, or null when the caller is in
300
+ * Agent ids of the caller's group members, or null when the caller is in
301
301
  * no group. Visibility is read live from storage on every operation, so a
302
- * group change takes effect immediately for every session.
302
+ * group change takes effect immediately for every agent.
303
303
  */
304
304
  private async myGroupMemberIds(): Promise<Set<string> | null> {
305
305
  const self = this.requireSelf();
306
- const group = await groupForSession(this.storage, self.sessionId);
306
+ const group = await groupForAgent(this.storage, self.agentId);
307
307
  return group ? new Set(group.members) : null;
308
308
  }
309
309
 
310
310
  /**
311
- * Resolve a target by its exact session id (uuid). Only co-members of the
312
- * caller's group are reachable; a session in no group sees no peers at all.
311
+ * Resolve a target by its exact agent id (uuid). Only co-members of the
312
+ * caller's group are reachable; an agent in no group sees no peers at all.
313
313
  */
314
314
  private async resolveTarget(to: string): Promise<TargetResult> {
315
315
  const self = this.requireSelf();
316
316
  const records = await listRecords(this.storage);
317
317
  const memberIds = await this.myGroupMemberIds();
318
- const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.sessionId));
319
- const target = others.find((r) => r.sessionId === to);
318
+ const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.agentId));
319
+ const target = others.find((r) => r.agentId === to);
320
320
  if (!target) return { ok: false, error: refusalUnknown(to) };
321
321
  return { ok: true, record: target };
322
322
  }
323
323
 
324
324
  private async sendLetter(
325
- target: SessionRecord,
325
+ target: AgentRecord,
326
326
  kind: LetterKind,
327
327
  body: string,
328
328
  replyTo?: string,
@@ -337,7 +337,7 @@ export class TalkCore {
337
337
  if (!verdict.ok) return { ok: false, error: verdict.reason };
338
338
  const letter: Letter = {
339
339
  id: newMessageId(),
340
- from: { addr: self.addr, name: self.name, cwd: self.cwd, sessionId: self.sessionId },
340
+ from: { addr: self.addr, name: self.name, cwd: self.cwd, agentId: self.agentId },
341
341
  kind,
342
342
  body,
343
343
  ts: this.now(),
@@ -351,9 +351,7 @@ export class TalkCore {
351
351
  ok: true,
352
352
  letter,
353
353
  verdict:
354
- receipt === "delivered"
355
- ? "delivered"
356
- : "queued (waits on disk until the session resumes)",
354
+ receipt === "delivered" ? "delivered" : "queued (waits on disk until the agent resumes)",
357
355
  };
358
356
  }
359
357
  return {
@@ -407,7 +405,7 @@ export class TalkCore {
407
405
  * - the later ask yields: its waiter is settled with a "peer asked first"
408
406
  * reason, and the peer's ask is delivered so this side answers it first.
409
407
  *
410
- * On a same-millisecond ts collision, session dir + session id (carried in
408
+ * On a same-millisecond ts collision, agent dir + agent id (carried in
411
409
  * `letter.from`) breaks the tie deterministically — both sides compute the
412
410
  * same comparison and reach symmetric conclusions.
413
411
  */
@@ -418,8 +416,8 @@ export class TalkCore {
418
416
  const waiter = this.askWaiters.get(myAsk.askId);
419
417
  if (!waiter) return;
420
418
  const peerFirst = peerAskedFirst(
421
- { ts: letter.ts, cwd: letter.from.cwd, sessionId: letter.from.sessionId },
422
- { ts: myAsk.ts, cwd: self.cwd, sessionId: self.sessionId },
419
+ { ts: letter.ts, cwd: letter.from.cwd, agentId: letter.from.agentId },
420
+ { ts: myAsk.ts, cwd: self.cwd, agentId: self.agentId },
423
421
  );
424
422
  if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
425
423
  this.askWaiters.delete(myAsk.askId);
@@ -433,10 +431,10 @@ export class TalkCore {
433
431
  // ── Tool actions ───────────────────────────────────────────────────────
434
432
 
435
433
  /**
436
- * JSON listing of visible sessions, including self (marked `self: true`).
437
- * Grouped sessions see only their co-members; a session in no group sees
434
+ * JSON listing of visible agents, including self (marked `self: true`).
435
+ * Grouped agents see only their co-members; an agent in no group sees
438
436
  * only itself. Every visible record is listed, live or offline; presence
439
- * decides the per-session status.
437
+ * decides the per-agent status.
440
438
  */
441
439
  async list(): Promise<string> {
442
440
  const self = this.requireSelf();
@@ -444,7 +442,7 @@ export class TalkCore {
444
442
  const memberIds = await this.myGroupMemberIds();
445
443
  const records = all.filter((r) => {
446
444
  if (r.addr === self.addr) return !this.dead;
447
- return memberIds?.has(r.sessionId) ?? false;
445
+ return memberIds?.has(r.agentId) ?? false;
448
446
  });
449
447
  return formatListing(records, self.addr, presenceOf);
450
448
  }
@@ -457,30 +455,30 @@ export class TalkCore {
457
455
  const filtered = records.filter((r) => {
458
456
  if (r.cwd !== cwd) return false;
459
457
  if (r.addr === self.addr) return !this.dead;
460
- return memberIds?.has(r.sessionId) ?? false;
458
+ return memberIds?.has(r.agentId) ?? false;
461
459
  });
462
460
  return formatListing(filtered, self.addr, presenceOf);
463
461
  }
464
462
 
465
463
  /** Visible peer records (excluding self), e.g. for command completions. */
466
- async listPeers(): Promise<SessionRecord[]> {
464
+ async listPeers(): Promise<AgentRecord[]> {
467
465
  const self = this.requireSelf();
468
466
  const records = await listRecords(this.storage);
469
467
  const memberIds = await this.myGroupMemberIds();
470
- return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.sessionId) ?? false));
468
+ return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.agentId) ?? false));
471
469
  }
472
470
 
473
471
  /**
474
- * Mark a session as dead: set its offline flag and pin lastSeenAt to 0 so
472
+ * Mark an agent as dead: set its offline flag and pin lastSeenAt to 0 so
475
473
  * the next sweep reaps it (empty mailbox). Without a target, marks this
476
- * session — later writeSelf calls no longer refresh lastSeenAt, and the
474
+ * agent — later writeSelf calls no longer refresh lastSeenAt, and the
477
475
  * record stays offline for peers.
478
476
  */
479
477
  async markDead(target?: string): Promise<string> {
480
478
  if (!target) {
481
479
  this.dead = true;
482
480
  await this.writeSelf({ offline: true });
483
- return "Marked this session as dead.";
481
+ return "Marked this agent as dead.";
484
482
  }
485
483
  const resolved = await this.resolveTarget(target);
486
484
  if (!resolved.ok) return resolved.error;
@@ -494,7 +492,7 @@ export class TalkCore {
494
492
  for (const peer of peers) {
495
493
  await writeRecord(this.storage, { ...peer, lastSeenAt: 0, offline: true });
496
494
  }
497
- return `Marked ${peers.length} session(s) as dead.`;
495
+ return `Marked ${peers.length} agent(s) as dead.`;
498
496
  }
499
497
 
500
498
  // ── Groups ─────────────────────────────────────────────────────────────
@@ -502,9 +500,9 @@ export class TalkCore {
502
500
  /** Remove the caller from its current group, deleting the group when it empties. */
503
501
  private async leaveCurrentGroup(): Promise<boolean> {
504
502
  const self = this.requireSelf();
505
- const group = await groupForSession(this.storage, self.sessionId);
503
+ const group = await groupForAgent(this.storage, self.agentId);
506
504
  if (!group) return false;
507
- const others = group.members.filter((m) => m !== self.sessionId);
505
+ const others = group.members.filter((m) => m !== self.agentId);
508
506
  if (others.length === 0) {
509
507
  await deleteGroup(this.storage, group.id);
510
508
  } else {
@@ -517,36 +515,42 @@ export class TalkCore {
517
515
  * Join or create a group and join it. With no name a fresh uuid is
518
516
  * generated; with a name, the group is joined when it exists and created
519
517
  * otherwise. Leaving any current group first keeps the single-group
520
- * invariant.
518
+ * invariant. When `agentName` is given, the agent's display name (what
519
+ * peers see in talk-list-agents) is set to it — also when the agent is
520
+ * already in the group, so re-joining can rename.
521
521
  */
522
- async groupJoin(groupName?: string): Promise<string> {
522
+ async groupJoin(groupName?: string, agentName?: string): Promise<string> {
523
523
  const self = this.requireSelf();
524
524
  const name =
525
525
  groupName === undefined || groupName.trim() === "" ? newGroupId() : groupName.trim();
526
526
  if (!isValidGroupName(name)) {
527
527
  return `Invalid group name '${name}'. Allowed: letters, digits, '-' and '_' (max 64 chars).`;
528
528
  }
529
+ if (agentName !== undefined) {
530
+ await this.writeSelf({ name: agentName });
531
+ }
532
+ const nameNote = agentName === undefined ? "" : ` You are visible as "${agentName}".`;
529
533
  const existing = await readGroup(this.storage, name);
530
- if (existing?.members.includes(self.sessionId)) {
531
- return `Already in group ${name} (${existing.members.length} member(s)).`;
534
+ if (existing?.members.includes(self.agentId)) {
535
+ return `Already in group ${name} (${existing.members.length} member(s)).${nameNote}`;
532
536
  }
533
537
  await this.leaveCurrentGroup();
534
538
  if (existing) {
535
539
  await writeGroup(this.storage, {
536
540
  ...existing,
537
- members: [...existing.members, self.sessionId],
541
+ members: [...existing.members, self.agentId],
538
542
  updatedAt: this.now(),
539
543
  });
540
- return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members.`;
544
+ return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members.${nameNote}`;
541
545
  }
542
546
  const now = this.now();
543
547
  await writeGroup(this.storage, {
544
548
  id: name,
545
- members: [self.sessionId],
549
+ members: [self.agentId],
546
550
  createdAt: now,
547
551
  updatedAt: now,
548
552
  });
549
- return `Created group ${name}. Other sessions join it with /talk-group-join ${name}.`;
553
+ return `Created group ${name}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
550
554
  }
551
555
 
552
556
  /** Join the most recently created group; no-op when already in it. */
@@ -560,9 +564,9 @@ export class TalkCore {
560
564
  /** Leave the current group; an emptied group is deleted. */
561
565
  async groupLeave(): Promise<string> {
562
566
  const self = this.requireSelf();
563
- const group = await groupForSession(this.storage, self.sessionId);
567
+ const group = await groupForAgent(this.storage, self.agentId);
564
568
  if (!group) return "Not in any group.";
565
- const others = group.members.filter((m) => m !== self.sessionId);
569
+ const others = group.members.filter((m) => m !== self.agentId);
566
570
  if (others.length === 0) {
567
571
  await deleteGroup(this.storage, group.id);
568
572
  return `Left group ${group.id} (deleted — it was empty).`;
@@ -581,7 +585,7 @@ export class TalkCore {
581
585
  return `Deleted group ${name} (${group.members.length} member(s)).`;
582
586
  }
583
587
 
584
- /** Delete every group; all sessions become ungrouped. */
588
+ /** Delete every group; all agents become ungrouped. */
585
589
  async groupClear(): Promise<string> {
586
590
  const groups = await listGroups(this.storage);
587
591
  for (const group of groups) await deleteGroup(this.storage, group.id);
@@ -597,17 +601,15 @@ export class TalkCore {
597
601
  const groups = await listGroups(this.storage);
598
602
  if (groups.length === 0) return "No groups.";
599
603
  const records = await listRecords(this.storage);
600
- const label = (sessionId: string): string => {
601
- const rec = records.find((r) => r.sessionId === sessionId);
602
- const id = sessionId.length > 8 ? `${sessionId.slice(0, 8)}…` : sessionId;
603
- return rec ? `${rec.name} (${id})` : `unknown session (${id})`;
604
+ const label = (agentId: string): string => {
605
+ const rec = records.find((r) => r.agentId === agentId);
606
+ const id = agentId.length > 8 ? `${agentId.slice(0, 8)}…` : agentId;
607
+ return rec ? `${rec.name} (${id})` : `unknown agent (${id})`;
604
608
  };
605
609
  const lines = groups
606
610
  .toSorted((a, b) => b.createdAt - a.createdAt)
607
611
  .map((g) => {
608
- const members = g.members.map((m) =>
609
- m === self.sessionId ? `${label(m)} ← you` : label(m),
610
- );
612
+ const members = g.members.map((m) => (m === self.agentId ? `${label(m)} ← you` : label(m)));
611
613
  return `- ${g.id} (created ${age(g.createdAt)}): ${members.join(", ")}`;
612
614
  });
613
615
  return `Groups (${groups.length}):\n${lines.join("\n")}`;
@@ -617,7 +619,7 @@ export class TalkCore {
617
619
  if (!to) return 'send requires "to".';
618
620
  if (!body) return 'send requires "message".';
619
621
  if (to === "*" || to === "cwd") {
620
- return "send requires a single session id; broadcasting is disabled.";
622
+ return "send requires a single agent id; broadcasting is disabled.";
621
623
  }
622
624
  const resolved = await this.resolveTarget(to);
623
625
  if (!resolved.ok) return resolved.error;
@@ -683,10 +685,10 @@ export class TalkCore {
683
685
  }
684
686
 
685
687
  /** Build a minimal record from a letter's sender when the peer record is gone. */
686
- private recordFromLetter(letter: Letter): SessionRecord {
688
+ private recordFromLetter(letter: Letter): AgentRecord {
687
689
  return {
688
690
  addr: letter.from.addr,
689
- sessionId: letter.from.sessionId,
691
+ agentId: letter.from.agentId,
690
692
  name: letter.from.name,
691
693
  cwd: letter.from.cwd,
692
694
  pid: 0,
@@ -4,10 +4,9 @@
4
4
  */
5
5
 
6
6
  import type { Letter } from "./mailbox.js";
7
- import type { Presence, SessionRecord } from "./registry.js";
7
+ import type { AgentRecord, Presence } from "./registry.js";
8
8
 
9
- export const BOUNDARY_PREAMBLE =
10
- "This came from another pi session (a peer agent), not from the user.";
9
+ export const BOUNDARY_PREAMBLE = "This came from another pi agent, not from the user.";
11
10
 
12
11
  export function shortAddr(addr: string): string {
13
12
  return addr.slice(0, 6);
@@ -21,20 +20,20 @@ export function age(ts: number, now: number = Date.now()): string {
21
20
  return `${Math.round(m / 60)}h ago`;
22
21
  }
23
22
 
24
- /** Delivery text injected into the receiving session's LLM context. */
23
+ /** Delivery text injected into the receiving agent's LLM context. */
25
24
  export function formatDelivery(letter: Letter, now: number = Date.now()): string {
26
25
  const from = letter.from;
27
- const header = `From pi session ${from.sessionId} (${from.cwd}) — "${from.name}"`;
26
+ const header = `From pi agent ${from.agentId} (${from.cwd}) — "${from.name}"`;
28
27
  const meta = `_id ${letter.id} · ${letter.kind} · sent ${age(letter.ts, now)}_`;
29
28
  const hint =
30
29
  letter.kind === "ask" ? `\n\nReply with the talk-reply tool, replyTo: "${letter.id}"` : "";
31
30
  return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
32
31
  }
33
32
 
34
- /** One session as the model sees it in a listing. `id` is the stable pi
35
- * session uuid; `name` is the display name when one was set; `self` marks
36
- * the calling session itself. */
37
- export interface SessionListItem {
33
+ /** One agent as the model sees it in a listing. `id` is the stable pi
34
+ * agent uuid; `name` is the display name when one was set; `self` marks
35
+ * the calling agent itself. */
36
+ export interface AgentListItem {
38
37
  status: string;
39
38
  work_dir: string;
40
39
  id: string;
@@ -42,17 +41,17 @@ export interface SessionListItem {
42
41
  self?: boolean;
43
42
  }
44
43
 
45
- /** Machine-readable JSON listing of sessions (what the model sees). */
44
+ /** Machine-readable JSON listing of agents (what the model sees). */
46
45
  export function formatListing(
47
- records: SessionRecord[],
46
+ records: AgentRecord[],
48
47
  selfAddr: string,
49
- presence: (r: SessionRecord) => Presence,
48
+ presence: (r: AgentRecord) => Presence,
50
49
  ): string {
51
50
  if (records.length === 0) return "[]";
52
- const items: SessionListItem[] = records.map((r) => {
51
+ const items: AgentListItem[] = records.map((r) => {
53
52
  const p = presence(r);
54
53
  const status = p === "live" ? r.status : "offline";
55
- const item: SessionListItem = { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
54
+ const item: AgentListItem = { status, work_dir: r.cwd, id: r.agentId, name: r.name };
56
55
  if (r.addr === selfAddr) item.self = true;
57
56
  return item;
58
57
  });
@@ -60,5 +59,5 @@ export function formatListing(
60
59
  }
61
60
 
62
61
  export function refusalUnknown(to: string): string {
63
- return `Unknown session id '${to}'. Get session ids with talk-list-sessions.`;
62
+ return `Unknown agent id '${to}'. Get agent ids with talk-list-agents.`;
64
63
  }
package/src/talk/group.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Session groups for the talk mailbox: an explicit, uuid-addressed set of
3
- * sessions that see only each other. Core layer — depends only on
2
+ * Agent groups for the talk mailbox: an explicit, uuid-addressed set of
3
+ * agents that see only each other. Core layer — depends only on
4
4
  * TalkStorage, never on pi.
5
5
  *
6
6
  * Rules:
7
- * - A session belongs to at most one group (single-group invariant).
8
- * - Groups are public: any session can join any group by its uuid, and a
7
+ * - An agent belongs to at most one group (single-group invariant).
8
+ * - Groups are public: any agent can join any group by its uuid, and a
9
9
  * member can leave freely. There is no owner.
10
- * - Visibility is fully group-driven: a grouped session sees only its
11
- * co-members; a session in no group sees only itself.
10
+ * - Visibility is fully group-driven: a grouped agent sees only its
11
+ * co-members; an agent in no group sees only itself.
12
12
  * - A group that empties is deleted; a one-member group is a normal state
13
13
  * (a creator waiting for peers to join).
14
14
  */
@@ -22,7 +22,7 @@ import type { TalkStorage } from "./storage.js";
22
22
 
23
23
  export const GroupSchema = Type.Object({
24
24
  id: Type.String(),
25
- /** pi session uuids of the members. */
25
+ /** pi agent uuids of the members. */
26
26
  members: Type.Array(Type.String()),
27
27
  createdAt: Type.Number(),
28
28
  updatedAt: Type.Number(),
@@ -79,16 +79,13 @@ export async function deleteGroup(storage: TalkStorage, id: string): Promise<boo
79
79
  }
80
80
 
81
81
  /**
82
- * The group a session currently belongs to. Under the single-group invariant
83
- * this is at most one; corrupted data that lists the session in several
82
+ * The group an agent currently belongs to. Under the single-group invariant
83
+ * this is at most one; corrupted data that lists the agent in several
84
84
  * groups resolves to the first match.
85
85
  */
86
- export async function groupForSession(
87
- storage: TalkStorage,
88
- sessionId: string,
89
- ): Promise<Group | null> {
86
+ export async function groupForAgent(storage: TalkStorage, agentId: string): Promise<Group | null> {
90
87
  for (const group of await listGroups(storage)) {
91
- if (group.members.includes(sessionId)) return group;
88
+ if (group.members.includes(agentId)) return group;
92
89
  }
93
90
  return null;
94
91
  }
package/src/talk/index.ts CHANGED
@@ -18,11 +18,12 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
18
  import { Box, Text } from "@earendil-works/pi-tui";
19
19
  import { Type } from "typebox";
20
20
 
21
+ import { parseArgs } from "../lib/cli-args.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) {
@@ -277,7 +282,7 @@ export default function talk(pi: ExtensionAPI) {
277
282
  // ── /talk commands ────────────────────────────────────────────────────
278
283
 
279
284
  pi.registerCommand("talk", {
280
- description: "List registered pi sessions",
285
+ description: "List registered pi agents",
281
286
  async handler() {
282
287
  const text = requireInit() ?? (await core.list());
283
288
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
@@ -286,7 +291,7 @@ export default function talk(pi: ExtensionAPI) {
286
291
 
287
292
  pi.registerCommand("talk-dead", {
288
293
  description:
289
- "Mark a talk session as dead (shown offline, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
294
+ "Mark a talk agent as dead (shown offline, swept soon): no arg = this agent, <agentId> = that agent, --all = every other visible agent",
290
295
  async handler(args) {
291
296
  const initError = requireInit();
292
297
  const trimmed = args.trim();
@@ -303,17 +308,21 @@ export default function talk(pi: ExtensionAPI) {
303
308
 
304
309
  pi.registerCommand("talk-group-join", {
305
310
  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",
311
+ "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 (e.g. --name frontend)",
307
312
  async handler(args) {
308
313
  const initError = requireInit();
309
- const token = args.trim();
310
- const text = initError ?? (await core.groupJoin(token || undefined));
314
+ const parsed = parseArgs(args);
315
+ const groupName = parsed.positionals[0];
316
+ const flag = parsed.flags.name;
317
+ const agentName = typeof flag === "string" && flag.trim() !== "" ? flag.trim() : undefined;
318
+ if (agentName !== undefined) explicitName = agentName;
319
+ const text = initError ?? (await core.groupJoin(groupName || undefined, agentName));
311
320
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
312
321
  },
313
322
  });
314
323
 
315
324
  pi.registerCommand("talk-group-join-last", {
316
- description: "Join the most recently created session group (no-op when already in it).",
325
+ description: "Join the most recently created agent group (no-op when already in it).",
317
326
  async handler() {
318
327
  const initError = requireInit();
319
328
  const text = initError ?? (await core.groupJoinLast());
@@ -322,7 +331,7 @@ export default function talk(pi: ExtensionAPI) {
322
331
  });
323
332
 
324
333
  pi.registerCommand("talk-group-leave", {
325
- description: "Leave the current session group (an emptied group is deleted).",
334
+ description: "Leave the current agent group (an emptied group is deleted).",
326
335
  async handler() {
327
336
  const initError = requireInit();
328
337
  const text = initError ?? (await core.groupLeave());
@@ -331,7 +340,7 @@ export default function talk(pi: ExtensionAPI) {
331
340
  });
332
341
 
333
342
  pi.registerCommand("talk-group-list", {
334
- description: "List all session groups and their members, newest first.",
343
+ description: "List all agent groups and their members, newest first.",
335
344
  async handler() {
336
345
  const initError = requireInit();
337
346
  const text = initError ?? (await core.groupList());
@@ -341,7 +350,7 @@ export default function talk(pi: ExtensionAPI) {
341
350
 
342
351
  pi.registerCommand("talk-group-del", {
343
352
  description:
344
- "Delete a session group by name; its members become ungrouped (see only themselves).",
353
+ "Delete an agent group by name; its members become ungrouped (see only themselves).",
345
354
  async handler(args) {
346
355
  const initError = requireInit();
347
356
  const name = args.trim();
@@ -352,7 +361,7 @@ export default function talk(pi: ExtensionAPI) {
352
361
  });
353
362
 
354
363
  pi.registerCommand("talk-group-clear", {
355
- description: "Delete every session group; all sessions become ungrouped.",
364
+ description: "Delete every agent group; all agents become ungrouped.",
356
365
  async handler() {
357
366
  const initError = requireInit();
358
367
  const text = initError ?? (await core.groupClear());
@@ -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;
@@ -1,21 +1,21 @@
1
1
  ---
2
2
  name: multi-agent-dev
3
- description: Coordinate multi-agent development across pi sessions using the talk extension. Explains how sessions discover and address each other, when to use talk-send vs talk-ask, and how to split work, exchange information, and review between agents. Use whenever you need to collaborate with other pi sessions or agents on the same machine.
3
+ description: Coordinate multi-agent development across pi agents using the talk extension. Explains how agents discover and address each other, when to use talk-send vs talk-ask, and how to split work, exchange information, and review between agents. Use whenever you need to collaborate with other agents on the same machine.
4
4
  ---
5
5
 
6
6
  # Multi-Agent Development with Talk
7
7
 
8
8
  ## Concept
9
9
 
10
- Multi-agent development runs several independent pi sessions in parallel and coordinates them over **talk**. Every session is a complete agent workspace — its own cwd, conversation history, and context. Talk lets sessions discover each other, exchange messages, ask questions, and sync progress.
10
+ Multi-agent development runs several independent pi agents in parallel and coordinates them over **talk**. Every agent is a complete workspace — its own cwd, conversation history, and context. Talk lets agents discover each other, exchange messages, ask questions, and sync progress.
11
11
 
12
- The core rule: **a peer only knows what you tell it.** Messages must be self-contained — background, goal, and constraints — because the receiving session has none of your context.
12
+ The core rule: **a peer only knows what you tell it.** Messages must be self-contained — background, goal, and constraints — because the receiving agent has none of your context.
13
13
 
14
- ## Session model
14
+ ## Agent model
15
15
 
16
16
  ### Discovery and addressing
17
17
 
18
- - `talk-list-sessions` returns sessions as JSON — **your own session is included and marked `self: true`** (also where you learn your own id):
18
+ - `talk-list-agents` returns agents as JSON — **your own agent is included and marked `self: true`** (also where you learn your own id):
19
19
 
20
20
  ```json
21
21
  [
@@ -29,55 +29,56 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
29
29
  ]
30
30
  ```
31
31
 
32
- - Addressing is **by session id only**: `talk-send` / `talk-ask` take the full `id` (pi session uuid). Names, paths, and prefixes are not accepted.
33
- - An unknown or invisible target is refused with `Unknown session id` — always list before sending.
32
+ - Addressing is **by agent id only**: `talk-send` / `talk-ask` take the full `id` (pi agent uuid). Names, paths, and prefixes are not accepted.
33
+ - An unknown or invisible target is refused with `Unknown agent id` — always list before sending.
34
34
 
35
35
  ### Status
36
36
 
37
37
  - `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a reply)
38
38
  - `offline` (process exited or marked dead)
39
- - `talk-list-sessions` lists every visible session — live or offline — with its current status.
39
+ - `talk-list-agents` lists every visible agent — live or offline — with its current status.
40
40
 
41
41
  ### Visibility
42
42
 
43
- - Visibility is fully group-driven: a session in a group sees only its co-members; a session in no group sees only itself. Ungrouped sessions are invisible to everyone.
43
+ - Visibility is fully group-driven: an agent in a group sees only its co-members; an agent in no group sees only itself. Ungrouped agents are invisible to everyone.
44
44
  - Groups are managed by the user from the TUI (`/talk-group-*` commands) — you cannot create, join, or leave a group yourself.
45
- - If a session you need to collaborate with is missing from `talk-list-sessions`, ask the user to pair the sessions into the same group.
45
+ - When the user joins a group they can tag their agent with a display name via `/talk-group-join <group> --name <alias>` (e.g. `frontend`, `backend`). That alias is what peers see as `name` in `talk-list-agents` prefer it over the raw agent id when describing who is who.
46
+ - If an agent you need to collaborate with is missing from `talk-list-agents`, ask the user to pair the agents into the same group.
46
47
 
47
48
  ## Tools
48
49
 
49
- | Tool | Purpose |
50
- | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
51
- | `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`); only group co-members (or only yourself when ungrouped) |
52
- | `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive) |
53
- | `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
54
- | `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
50
+ | Tool | Purpose |
51
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
52
+ | `talk-list-agents` | List visible agents (`id` / `status` / `work_dir` / `name`); only group co-members (or only yourself when ungrouped) |
53
+ | `talk-send` | Send a plain message to a single agent id (async — the main collaboration primitive) |
54
+ | `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
55
+ | `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
55
56
 
56
- Pairing into groups is a user action (`/talk-group-*` in the TUI); you only observe its effect through `talk-list-sessions`.
57
+ Pairing into groups is a user action (`/talk-group-*` in the TUI); you only observe its effect through `talk-list-agents`.
57
58
 
58
59
  ## Collaboration workflows
59
60
 
60
- ### Split work between sessions
61
+ ### Split work between agents
61
62
 
62
- 1. `talk-list-sessions` first: see which co-members exist, their `work_dir`, and status. If only yourself shows up, the peer sessions are not in your group yet — ask the user to pair them.
63
+ 1. `talk-list-agents` first: see which co-members exist, their `work_dir`, and status. If only yourself shows up, the peer agents are not in your group yet — ask the user to pair them.
63
64
  2. Assign work by module/files with `talk-send` — state the scope, boundaries, and expected output.
64
- 3. Each session completes its slice, then sends the result or a review request.
65
+ 3. Each agent completes its slice, then sends the result or a review request.
65
66
  4. Sync progress periodically to avoid overlapping edits.
66
67
 
67
68
  ### Synchronous question/answer (need the answer to continue)
68
69
 
69
70
  - Use `talk-ask` when the next step depends on the peer's information and the peer is reachable.
70
71
  - On receiving an ask, reply with `talk-reply` using the `replyTo` id from the delivered message.
71
- - If two sessions ask each other simultaneously: the later asker yields — answer the peer's ask first, then re-ask.
72
+ - If two agents ask each other simultaneously: the later asker yields — answer the peer's ask first, then re-ask.
72
73
 
73
74
  ### Async notifications
74
75
 
75
76
  - Use `talk-send` for heads-ups that do not block: send and keep working.
76
77
  - Messages deliver on the next natural turn by default (`queue`); `steer` interrupts the peer immediately — behavior depends on the `talk.deliver` setting.
77
78
 
78
- ### Cross-session review
79
+ ### Cross-agent review
79
80
 
80
- - Ask another session to review your changes: `talk-send` the file paths plus a diff summary, request a review, and let it reply.
81
+ - Ask another agent to review your changes: `talk-send` the file paths plus a diff summary, request a review, and let it reply.
81
82
  - Send paths and summaries, not whole file contents — the peer can `read` them itself.
82
83
 
83
84
  ## Message style
@@ -91,6 +92,6 @@ Pairing into groups is a user action (`/talk-group-*` in the TUI); you only obse
91
92
  ## Pitfalls
92
93
 
93
94
  - **Avoid message loops**: if the peer sent you something or is asking you, answer it before sending new ones. Two agents pinging each other deadlock.
94
- - **Address from known ids**: only run `talk-list-sessions` to discover sessions or verify an id. If you already hold a valid id (e.g. from an incoming message or a previous listing), send directly — an unknown or invisible id is refused with `Unknown session id`.
95
- - **Respect status**: asking an offline session blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
96
- - **Visibility boundary**: you can only collaborate with sessions that share your group; ungrouped sessions and other groups' members are unreachable by design. Ask the user to pair sessions before collaborating.
95
+ - **Address from known ids**: only run `talk-list-agents` to discover agents or verify an id. If you already hold a valid id (e.g. from an incoming message or a previous listing), send directly — an unknown or invisible id is refused with `Unknown agent id`.
96
+ - **Respect status**: asking an offline agent blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
97
+ - **Visibility boundary**: you can only collaborate with agents that share your group; ungrouped agents and other groups' members are unreachable by design. Ask the user to pair agents before collaborating.
@@ -45,7 +45,7 @@ export interface TalkStorage {
45
45
  * SQLite backend (Node's built-in `node:sqlite`, no npm dependency).
46
46
  *
47
47
  * A single database file holds everything; WAL mode plus a busy timeout lets
48
- * multiple pi sessions read and write it concurrently. SQL parameter binding
48
+ * multiple pi agents read and write it concurrently. SQL parameter binding
49
49
  * removes the need for path/symlink hardening entirely.
50
50
  *
51
51
  * The backend is synchronous; each method wraps its result in a resolved