@trim21/personal-pi-extensions 0.0.206 → 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/src/talk/index.ts CHANGED
@@ -18,12 +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 { jsoncToJson } from "../lib/jsonc.js";
21
+ import { parseArgs } from "../lib/cli-args.js";
22
22
  import { resolveHomePath } from "../lib/path.js";
23
- import { buildVisibilityFilter, TalkCore } from "./core.js";
23
+ import { TalkCore } from "./core.js";
24
24
  import { formatDelivery } from "./format.js";
25
25
  import type { Letter } from "./mailbox.js";
26
- import { deriveAddr, type SessionRecord } from "./registry.js";
26
+ import { type AgentRecord, deriveAddr } from "./registry.js";
27
27
  import { SqliteTalkStorage } from "./storage.js";
28
28
 
29
29
  const DELIVERY_TYPE = "talk:delivery";
@@ -103,41 +103,22 @@ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
103
103
  }
104
104
  }
105
105
 
106
- /**
107
- * Read the workspace visibility config from `<cwd>/.pi/talk.json`:
108
- * `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
109
- * (everything visible); an explicit `"allowed": []` hides every peer.
110
- */
111
- function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
112
- const configPath = path.join(cwd, ".pi", "talk.json");
113
- try {
114
- const raw = fs.readFileSync(configPath, "utf8");
115
- const parsed = JSON.parse(jsoncToJson(raw)) as { allowed?: unknown };
116
- if (Array.isArray(parsed.allowed)) {
117
- return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
118
- }
119
- return {};
120
- } catch (error) {
121
- // eslint-disable-next-line no-console -- config errors must be visible, not silent
122
- console.error(`Warning: could not parse ${configPath}: ${String(error)}`);
123
- return {};
124
- }
125
- }
126
-
127
106
  export default function talk(pi: ExtensionAPI) {
128
107
  const settings = readTalkSettings();
129
108
  const configured = process.env.PI_TALK_DB ?? settings.dbPath;
130
109
  const dbPath = configured
131
110
  ? resolveHomePath(configured, getAgentDir())
132
111
  : path.join(getAgentDir(), "talk.db");
133
- // "queue": deliver on the session's next natural turn without waking it;
134
- // "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.
135
114
  const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
136
115
  const storage = new SqliteTalkStorage(dbPath);
137
116
 
138
- 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;
139
120
 
140
- function deliverToSession(letter: Letter): boolean {
121
+ function deliverToAgent(letter: Letter): boolean {
141
122
  const details: DeliveryDetails = {
142
123
  id: letter.id,
143
124
  kind: letter.kind,
@@ -164,7 +145,7 @@ export default function talk(pi: ExtensionAPI) {
164
145
  const core = new TalkCore({
165
146
  storage,
166
147
  events: {
167
- deliver: deliverToSession,
148
+ deliver: deliverToAgent,
168
149
  notify(content) {
169
150
  // Presence transitions are informational — queue for the next turn
170
151
  // rather than steering into a busy agent.
@@ -184,20 +165,19 @@ export default function talk(pi: ExtensionAPI) {
184
165
  // ── Lifecycle ──────────────────────────────────────────────────────────
185
166
 
186
167
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
187
- const sessionId = ctx.sessionManager.getSessionId();
168
+ const agentId = ctx.sessionManager.getSessionId();
188
169
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
189
170
  const now = Date.now();
190
171
  self = {
191
- addr: deriveAddr(cwd, sessionId),
192
- sessionId,
193
- name: pi.getSessionName() ?? "Unnamed session",
172
+ addr: deriveAddr(cwd, agentId),
173
+ agentId,
174
+ name: pi.getSessionName() ?? "Unnamed agent",
194
175
  cwd,
195
176
  pid: process.pid,
196
177
  startedAt: now,
197
178
  lastSeenAt: now,
198
179
  status: "idle",
199
180
  };
200
- core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
201
181
  void core.start(self);
202
182
  });
203
183
 
@@ -205,15 +185,17 @@ export default function talk(pi: ExtensionAPI) {
205
185
  pi.on("agent_end", () => core.setIdle());
206
186
  pi.on("agent_settled", () => core.setIdle());
207
187
  pi.on("before_agent_start", (event) => {
208
- // One-line nudge: before coordinating with other pi sessions, read the
188
+ // One-line nudge: before coordinating with other pi agents, read the
209
189
  // shipped workflow skill. Skipped when the skill file is absent.
210
190
  if (!fs.existsSync(SKILL_PATH)) return;
211
191
  return {
212
- 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.`,
213
193
  };
214
194
  });
215
195
  pi.on("session_info_changed", () => {
216
- 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);
217
199
  });
218
200
  pi.on("session_shutdown", () => {
219
201
  void core.stop();
@@ -222,12 +204,12 @@ export default function talk(pi: ExtensionAPI) {
222
204
  // ── Tools ──────────────────────────────────────────────────────────────
223
205
 
224
206
  pi.registerTool({
225
- name: "talk-list-sessions",
226
- label: "List Talk Sessions",
227
- description: "List visible pi sessions (id, status, work_dir, name).",
228
- 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",
229
211
  parameters: Type.Object({
230
- 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" })),
231
213
  }),
232
214
  async execute(_toolCallId, params) {
233
215
  const initError = requireInit();
@@ -240,10 +222,10 @@ export default function talk(pi: ExtensionAPI) {
240
222
  name: "talk-ask",
241
223
  label: "Ask Talk",
242
224
  description:
243
- "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.",
244
- 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",
245
227
  parameters: Type.Object({
246
- to: Type.String({ description: "Target session (name/address/@alias)" }),
228
+ to: Type.String({ description: "Target agent (name/address/@alias)" }),
247
229
  message: Type.String({ description: "The question" }),
248
230
  timeoutMs: Type.Optional(
249
231
  Type.Number({ description: `Wait cap in ms; default ${ASK_TIMEOUT_MS}` }),
@@ -267,10 +249,10 @@ export default function talk(pi: ExtensionAPI) {
267
249
  name: "talk-send",
268
250
  label: "Send Talk Message",
269
251
  description:
270
- "Send a plain-text message to a single pi session. Plain text only, ≤32KB — send a summary and a path, never file contents.",
271
- 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",
272
254
  parameters: Type.Object({
273
- to: Type.String({ description: "Target session id (from talk-list-sessions)" }),
255
+ to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
274
256
  message: Type.String({ description: "Message body" }),
275
257
  }),
276
258
  async execute(_toolCallId, params) {
@@ -300,7 +282,7 @@ export default function talk(pi: ExtensionAPI) {
300
282
  // ── /talk commands ────────────────────────────────────────────────────
301
283
 
302
284
  pi.registerCommand("talk", {
303
- description: "List registered pi sessions",
285
+ description: "List registered pi agents",
304
286
  async handler() {
305
287
  const text = requireInit() ?? (await core.list());
306
288
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
@@ -309,7 +291,7 @@ export default function talk(pi: ExtensionAPI) {
309
291
 
310
292
  pi.registerCommand("talk-dead", {
311
293
  description:
312
- "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",
313
295
  async handler(args) {
314
296
  const initError = requireInit();
315
297
  const trimmed = args.trim();
@@ -324,6 +306,69 @@ export default function talk(pi: ExtensionAPI) {
324
306
  },
325
307
  });
326
308
 
309
+ pi.registerCommand("talk-group-join", {
310
+ description:
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)",
312
+ async handler(args) {
313
+ const initError = requireInit();
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));
320
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
321
+ },
322
+ });
323
+
324
+ pi.registerCommand("talk-group-join-last", {
325
+ description: "Join the most recently created agent group (no-op when already in it).",
326
+ async handler() {
327
+ const initError = requireInit();
328
+ const text = initError ?? (await core.groupJoinLast());
329
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
330
+ },
331
+ });
332
+
333
+ pi.registerCommand("talk-group-leave", {
334
+ description: "Leave the current agent group (an emptied group is deleted).",
335
+ async handler() {
336
+ const initError = requireInit();
337
+ const text = initError ?? (await core.groupLeave());
338
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
339
+ },
340
+ });
341
+
342
+ pi.registerCommand("talk-group-list", {
343
+ description: "List all agent groups and their members, newest first.",
344
+ async handler() {
345
+ const initError = requireInit();
346
+ const text = initError ?? (await core.groupList());
347
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
348
+ },
349
+ });
350
+
351
+ pi.registerCommand("talk-group-del", {
352
+ description:
353
+ "Delete an agent group by name; its members become ungrouped (see only themselves).",
354
+ async handler(args) {
355
+ const initError = requireInit();
356
+ const name = args.trim();
357
+ const text =
358
+ initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
359
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
360
+ },
361
+ });
362
+
363
+ pi.registerCommand("talk-group-clear", {
364
+ description: "Delete every agent group; all agents become ungrouped.",
365
+ async handler() {
366
+ const initError = requireInit();
367
+ const text = initError ?? (await core.groupClear());
368
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
369
+ },
370
+ });
371
+
327
372
  // ── Delivery card ──────────────────────────────────────────────────────
328
373
 
329
374
  pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
@@ -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,54 +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
- - Each workspace controls what it can see via `allowed` in `<cwd>/.pi/talk.json` (path prefixes). Sessions outside the prefixes are neither listed nor addressable.
44
- - Visibility is one-way: you seeing a session does not mean it sees you.
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
+ - Groups are managed by the user from the TUI (`/talk-group-*` commands) — you cannot create, join, or leave a group yourself.
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.
45
47
 
46
48
  ## Tools
47
49
 
48
- | Tool | Purpose |
49
- | -------------------- | -------------------------------------------------------------------------------------- |
50
- | `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`) |
51
- | `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive) |
52
- | `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
53
- | `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 |
54
56
 
55
- In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown offline, swept soon).
57
+ Pairing into groups is a user action (`/talk-group-*` in the TUI); you only observe its effect through `talk-list-agents`.
56
58
 
57
59
  ## Collaboration workflows
58
60
 
59
- ### Split work between sessions
61
+ ### Split work between agents
60
62
 
61
- 1. `talk-list-sessions` first: see which sessions exist, their `work_dir`, and status.
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.
62
64
  2. Assign work by module/files with `talk-send` — state the scope, boundaries, and expected output.
63
- 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.
64
66
  4. Sync progress periodically to avoid overlapping edits.
65
67
 
66
68
  ### Synchronous question/answer (need the answer to continue)
67
69
 
68
70
  - Use `talk-ask` when the next step depends on the peer's information and the peer is reachable.
69
71
  - On receiving an ask, reply with `talk-reply` using the `replyTo` id from the delivered message.
70
- - 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.
71
73
 
72
74
  ### Async notifications
73
75
 
74
76
  - Use `talk-send` for heads-ups that do not block: send and keep working.
75
77
  - Messages deliver on the next natural turn by default (`queue`); `steer` interrupts the peer immediately — behavior depends on the `talk.deliver` setting.
76
78
 
77
- ### Cross-session review
79
+ ### Cross-agent review
78
80
 
79
- - 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.
80
82
  - Send paths and summaries, not whole file contents — the peer can `read` them itself.
81
83
 
82
84
  ## Message style
@@ -90,6 +92,6 @@ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown
90
92
  ## Pitfalls
91
93
 
92
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.
93
- - **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`.
94
- - **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.
95
- - **Visibility boundary**: you can only collaborate with sessions you can see; invisible sessions are unreachable by design.
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.
@@ -16,6 +16,8 @@
16
16
  * blindly cast.
17
17
  */
18
18
 
19
+ import { mkdirSync } from "node:fs";
20
+ import { dirname } from "node:path";
19
21
  import { DatabaseSync } from "node:sqlite";
20
22
 
21
23
  export interface TalkStorage {
@@ -43,7 +45,7 @@ export interface TalkStorage {
43
45
  * SQLite backend (Node's built-in `node:sqlite`, no npm dependency).
44
46
  *
45
47
  * A single database file holds everything; WAL mode plus a busy timeout lets
46
- * multiple pi sessions read and write it concurrently. SQL parameter binding
48
+ * multiple pi agents read and write it concurrently. SQL parameter binding
47
49
  * removes the need for path/symlink hardening entirely.
48
50
  *
49
51
  * The backend is synchronous; each method wraps its result in a resolved
@@ -53,6 +55,10 @@ export class SqliteTalkStorage implements TalkStorage {
53
55
  private readonly db: DatabaseSync;
54
56
 
55
57
  constructor(dbPath: string) {
58
+ // A custom db_path may point into a directory that does not exist yet
59
+ // (e.g. "~/data/talk.db"); sqlite refuses to open it, so create the
60
+ // parent directory first.
61
+ mkdirSync(dirname(dbPath), { recursive: true });
56
62
  this.db = new DatabaseSync(dbPath);
57
63
  this.db.exec("PRAGMA journal_mode = WAL");
58
64
  this.db.exec("PRAGMA busy_timeout = 5000");