@trim21/personal-pi-extensions 0.0.208 → 0.0.210
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bwrap/index.ts +87 -25
- package/src/lib/cli-args.ts +116 -0
- package/src/lib/cli.ts +340 -0
- package/src/talk/core.ts +72 -70
- package/src/talk/format.ts +14 -15
- package/src/talk/group.ts +11 -14
- package/src/talk/index.ts +156 -84
- package/src/talk/mailbox.ts +27 -6
- package/src/talk/policy.ts +1 -1
- package/src/talk/registry.ts +50 -27
- package/src/talk/skills/multi-agent-dev/SKILL.md +27 -26
- package/src/talk/storage.ts +1 -1
package/src/talk/index.ts
CHANGED
|
@@ -16,13 +16,14 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
19
|
-
import { Type } from "typebox";
|
|
19
|
+
import { type TObject, Type } from "typebox";
|
|
20
20
|
|
|
21
|
+
import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
21
22
|
import { resolveHomePath } from "../lib/path.js";
|
|
22
23
|
import { TalkCore } from "./core.js";
|
|
23
24
|
import { formatDelivery } from "./format.js";
|
|
24
25
|
import type { Letter } from "./mailbox.js";
|
|
25
|
-
import {
|
|
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
|
|
112
|
-
// "steer": interrupt mid-run / wake an idle
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
170
|
-
|
|
171
|
-
name: pi.getSessionName() ?? "Unnamed
|
|
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
|
|
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-
|
|
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
|
-
|
|
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-
|
|
203
|
-
label: "List Talk
|
|
204
|
-
description: "List visible pi
|
|
205
|
-
promptSnippet: "List other pi
|
|
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
|
|
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
|
|
221
|
-
promptSnippet: "Ask another pi
|
|
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
|
|
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
|
|
248
|
-
promptSnippet: "Send a message to another pi
|
|
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
|
|
255
|
+
to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
|
|
251
256
|
message: Type.String({ description: "Message body" }),
|
|
252
257
|
}),
|
|
253
258
|
async execute(_toolCallId, params) {
|
|
@@ -276,88 +281,155 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
276
281
|
|
|
277
282
|
// ── /talk commands ────────────────────────────────────────────────────
|
|
278
283
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
284
|
+
type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
|
|
285
|
+
|
|
286
|
+
/** Parse a /talk command; on help/error or init failure the text is sent, otherwise run() produces the listing text. */
|
|
287
|
+
function handleCommand<TFlags extends TObject>(
|
|
288
|
+
spec: CommandSpec<TFlags>,
|
|
289
|
+
args: string,
|
|
290
|
+
run: (parsed: OkResult<TFlags>) => Promise<string> | string,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const parsed = parseCommand(spec, args);
|
|
293
|
+
if (parsed.kind !== "ok") {
|
|
294
|
+
pi.sendMessage({ customType: LIST_TYPE, content: parsed.text, display: true });
|
|
295
|
+
return Promise.resolve();
|
|
296
|
+
}
|
|
297
|
+
return (async () => {
|
|
298
|
+
const initError = requireInit();
|
|
299
|
+
const text = initError ?? (await run(parsed));
|
|
283
300
|
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
284
|
-
}
|
|
301
|
+
})();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const TALK_SPEC = {
|
|
305
|
+
name: "talk",
|
|
306
|
+
usage: "",
|
|
307
|
+
description: "List registered pi agents",
|
|
308
|
+
flags: Type.Object({}),
|
|
309
|
+
arity: { max: 0 },
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const TALK_DEAD_SPEC = {
|
|
313
|
+
name: "talk-dead",
|
|
314
|
+
usage: "[agentId] [options]",
|
|
315
|
+
description:
|
|
316
|
+
"Mark a talk agent as dead (shown offline, swept soon): no arg = this agent, <agentId> = that agent, --all = every other visible agent",
|
|
317
|
+
flags: Type.Object({
|
|
318
|
+
all: Type.Optional(Type.Boolean({ description: "Mark every other visible agent dead" })),
|
|
319
|
+
}),
|
|
320
|
+
flagMeta: { all: { short: "a" } },
|
|
321
|
+
arity: { max: 1 },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const TALK_GROUP_JOIN_SPEC = {
|
|
325
|
+
name: "talk-group-join",
|
|
326
|
+
usage: "[group name] [options]",
|
|
327
|
+
description:
|
|
328
|
+
"Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name",
|
|
329
|
+
flags: Type.Object({
|
|
330
|
+
name: Type.Optional(Type.String({ description: "Set this agent's display name" })),
|
|
331
|
+
}),
|
|
332
|
+
flagMeta: { name: { short: "n", valuePlaceholder: "<alias>" } },
|
|
333
|
+
arity: { max: 1 },
|
|
334
|
+
examples: ["/talk-group-join frontend", "/talk-group-join --name frontend"],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const TALK_GROUP_JOIN_LAST_SPEC = {
|
|
338
|
+
name: "talk-group-join-last",
|
|
339
|
+
usage: "",
|
|
340
|
+
description: "Join the most recently created agent group (no-op when already in it).",
|
|
341
|
+
flags: Type.Object({}),
|
|
342
|
+
arity: { max: 0 },
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const TALK_GROUP_LEAVE_SPEC = {
|
|
346
|
+
name: "talk-group-leave",
|
|
347
|
+
usage: "",
|
|
348
|
+
description: "Leave the current agent group (an emptied group is deleted).",
|
|
349
|
+
flags: Type.Object({}),
|
|
350
|
+
arity: { max: 0 },
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const TALK_GROUP_LIST_SPEC = {
|
|
354
|
+
name: "talk-group-list",
|
|
355
|
+
usage: "",
|
|
356
|
+
description: "List all agent groups and their members, newest first.",
|
|
357
|
+
flags: Type.Object({}),
|
|
358
|
+
arity: { max: 0 },
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const TALK_GROUP_DEL_SPEC = {
|
|
362
|
+
name: "talk-group-del",
|
|
363
|
+
usage: "<group name>",
|
|
364
|
+
description:
|
|
365
|
+
"Delete an agent group by name; its members become ungrouped (see only themselves).",
|
|
366
|
+
flags: Type.Object({}),
|
|
367
|
+
arity: { min: 1, max: 1 },
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const TALK_GROUP_CLEAR_SPEC = {
|
|
371
|
+
name: "talk-group-clear",
|
|
372
|
+
usage: "",
|
|
373
|
+
description: "Delete every agent group; all agents become ungrouped.",
|
|
374
|
+
flags: Type.Object({}),
|
|
375
|
+
arity: { max: 0 },
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
pi.registerCommand("talk", {
|
|
379
|
+
description: TALK_SPEC.description,
|
|
380
|
+
handler: (args) => handleCommand(TALK_SPEC, args, async () => core.list()),
|
|
285
381
|
});
|
|
286
382
|
|
|
287
383
|
pi.registerCommand("talk-dead", {
|
|
288
|
-
description:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
(trimmed === "--all"
|
|
384
|
+
description: TALK_DEAD_SPEC.description,
|
|
385
|
+
handler: (args) =>
|
|
386
|
+
handleCommand(TALK_DEAD_SPEC, args, async (parsed) => {
|
|
387
|
+
if (parsed.flags.all && parsed.args.length > 0) {
|
|
388
|
+
return "--all cannot be combined with an agent id.\nTry '/talk-dead --help' for usage.";
|
|
389
|
+
}
|
|
390
|
+
return parsed.flags.all
|
|
296
391
|
? await core.markAllDead()
|
|
297
|
-
:
|
|
298
|
-
? await core.markDead(
|
|
299
|
-
: await core.markDead()
|
|
300
|
-
|
|
301
|
-
},
|
|
392
|
+
: parsed.args[0]
|
|
393
|
+
? await core.markDead(parsed.args[0])
|
|
394
|
+
: await core.markDead();
|
|
395
|
+
}),
|
|
302
396
|
});
|
|
303
397
|
|
|
304
398
|
pi.registerCommand("talk-group-join", {
|
|
305
|
-
description:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
},
|
|
399
|
+
description: TALK_GROUP_JOIN_SPEC.description,
|
|
400
|
+
handler: (args) =>
|
|
401
|
+
handleCommand(TALK_GROUP_JOIN_SPEC, args, async (parsed) => {
|
|
402
|
+
const agentName = parsed.flags.name?.trim() || undefined;
|
|
403
|
+
if (agentName !== undefined) explicitName = agentName;
|
|
404
|
+
return core.groupJoin(parsed.args[0], agentName);
|
|
405
|
+
}),
|
|
313
406
|
});
|
|
314
407
|
|
|
315
408
|
pi.registerCommand("talk-group-join-last", {
|
|
316
|
-
description:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const text = initError ?? (await core.groupJoinLast());
|
|
320
|
-
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
321
|
-
},
|
|
409
|
+
description: TALK_GROUP_JOIN_LAST_SPEC.description,
|
|
410
|
+
handler: (args) =>
|
|
411
|
+
handleCommand(TALK_GROUP_JOIN_LAST_SPEC, args, async () => core.groupJoinLast()),
|
|
322
412
|
});
|
|
323
413
|
|
|
324
414
|
pi.registerCommand("talk-group-leave", {
|
|
325
|
-
description:
|
|
326
|
-
async
|
|
327
|
-
const initError = requireInit();
|
|
328
|
-
const text = initError ?? (await core.groupLeave());
|
|
329
|
-
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
330
|
-
},
|
|
415
|
+
description: TALK_GROUP_LEAVE_SPEC.description,
|
|
416
|
+
handler: (args) => handleCommand(TALK_GROUP_LEAVE_SPEC, args, async () => core.groupLeave()),
|
|
331
417
|
});
|
|
332
418
|
|
|
333
419
|
pi.registerCommand("talk-group-list", {
|
|
334
|
-
description:
|
|
335
|
-
async
|
|
336
|
-
const initError = requireInit();
|
|
337
|
-
const text = initError ?? (await core.groupList());
|
|
338
|
-
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
339
|
-
},
|
|
420
|
+
description: TALK_GROUP_LIST_SPEC.description,
|
|
421
|
+
handler: (args) => handleCommand(TALK_GROUP_LIST_SPEC, args, async () => core.groupList()),
|
|
340
422
|
});
|
|
341
423
|
|
|
342
424
|
pi.registerCommand("talk-group-del", {
|
|
343
|
-
description:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const initError = requireInit();
|
|
347
|
-
const name = args.trim();
|
|
348
|
-
const text =
|
|
349
|
-
initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
|
|
350
|
-
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
351
|
-
},
|
|
425
|
+
description: TALK_GROUP_DEL_SPEC.description,
|
|
426
|
+
handler: (args) =>
|
|
427
|
+
handleCommand(TALK_GROUP_DEL_SPEC, args, async (parsed) => core.groupDelete(parsed.args[0])),
|
|
352
428
|
});
|
|
353
429
|
|
|
354
430
|
pi.registerCommand("talk-group-clear", {
|
|
355
|
-
description:
|
|
356
|
-
async
|
|
357
|
-
const initError = requireInit();
|
|
358
|
-
const text = initError ?? (await core.groupClear());
|
|
359
|
-
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
360
|
-
},
|
|
431
|
+
description: TALK_GROUP_CLEAR_SPEC.description,
|
|
432
|
+
handler: (args) => handleCommand(TALK_GROUP_CLEAR_SPEC, args, async () => core.groupClear()),
|
|
361
433
|
});
|
|
362
434
|
|
|
363
435
|
// ── Delivery card ──────────────────────────────────────────────────────
|
package/src/talk/mailbox.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
265
|
+
const letter = normalizeLetter(raw);
|
|
266
|
+
if (letter) out.push(letter);
|
|
246
267
|
}
|
|
247
268
|
return out;
|
|
248
269
|
}
|
package/src/talk/policy.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/src/talk/registry.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
8
|
-
* two
|
|
9
|
-
* - A record outlives the process that wrote it — that's what makes
|
|
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
|
-
|
|
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:
|
|
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,
|
|
57
|
-
return createHash("sha256").update(`${cwd}${
|
|
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
|
-
// ──
|
|
104
|
+
// ── Agent records ────────────────────────────────────────────────────────
|
|
85
105
|
|
|
86
|
-
export async function writeRecord(storage: TalkStorage, record:
|
|
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
|
-
|
|
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<
|
|
100
|
-
const out:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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;
|