@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 +1 -1
- package/src/lib/cli-args.ts +116 -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 +43 -34
- 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/package.json
CHANGED
|
@@ -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.
|
|
7
|
-
* group sees only its co-members;
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 +
|
|
87
|
+
* collision, `cwd + agentId` breaks the tie deterministically.
|
|
88
88
|
*/
|
|
89
89
|
export function peerAskedFirst(
|
|
90
|
-
peer: { ts: number; cwd: string;
|
|
91
|
-
self: { ts: number; cwd: 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.
|
|
94
|
-
const selfKey = `${self.cwd}\u0000${self.
|
|
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:
|
|
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():
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
186
|
+
setAgentName(name: string): void {
|
|
187
187
|
void this.writeSelf({ name });
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
private async writeSelf(patch: Partial<
|
|
190
|
+
private async writeSelf(patch: Partial<AgentRecord>): Promise<void> {
|
|
191
191
|
if (!this.self) return;
|
|
192
|
-
// A dead
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
312
|
-
* caller's group are reachable;
|
|
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.
|
|
319
|
-
const target = others.find((r) => r.
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
422
|
-
{ ts: myAsk.ts, cwd: self.cwd,
|
|
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
|
|
437
|
-
* Grouped
|
|
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-
|
|
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.
|
|
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.
|
|
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<
|
|
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.
|
|
468
|
+
return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.agentId) ?? false));
|
|
471
469
|
}
|
|
472
470
|
|
|
473
471
|
/**
|
|
474
|
-
* Mark
|
|
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
|
-
*
|
|
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
|
|
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}
|
|
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
|
|
503
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
506
504
|
if (!group) return false;
|
|
507
|
-
const others = group.members.filter((m) => m !== self.
|
|
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.
|
|
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.
|
|
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.
|
|
549
|
+
members: [self.agentId],
|
|
546
550
|
createdAt: now,
|
|
547
551
|
updatedAt: now,
|
|
548
552
|
});
|
|
549
|
-
return `Created group ${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
|
|
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.
|
|
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
|
|
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 = (
|
|
601
|
-
const rec = records.find((r) => r.
|
|
602
|
-
const id =
|
|
603
|
-
return rec ? `${rec.name} (${id})` : `unknown
|
|
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
|
|
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):
|
|
688
|
+
private recordFromLetter(letter: Letter): AgentRecord {
|
|
687
689
|
return {
|
|
688
690
|
addr: letter.from.addr,
|
|
689
|
-
|
|
691
|
+
agentId: letter.from.agentId,
|
|
690
692
|
name: letter.from.name,
|
|
691
693
|
cwd: letter.from.cwd,
|
|
692
694
|
pid: 0,
|
package/src/talk/format.ts
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Letter } from "./mailbox.js";
|
|
7
|
-
import type {
|
|
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
|
|
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
|
|
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
|
|
35
|
-
*
|
|
36
|
-
* the calling
|
|
37
|
-
export interface
|
|
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
|
|
44
|
+
/** Machine-readable JSON listing of agents (what the model sees). */
|
|
46
45
|
export function formatListing(
|
|
47
|
-
records:
|
|
46
|
+
records: AgentRecord[],
|
|
48
47
|
selfAddr: string,
|
|
49
|
-
presence: (r:
|
|
48
|
+
presence: (r: AgentRecord) => Presence,
|
|
50
49
|
): string {
|
|
51
50
|
if (records.length === 0) return "[]";
|
|
52
|
-
const items:
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
* -
|
|
8
|
-
* - Groups are public: any
|
|
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
|
|
11
|
-
* co-members;
|
|
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
|
|
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
|
|
83
|
-
* this is at most one; corrupted data that lists the
|
|
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
|
|
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(
|
|
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 {
|
|
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) {
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
310
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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());
|
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;
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: multi-agent-dev
|
|
3
|
-
description: Coordinate multi-agent development across pi
|
|
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
|
|
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
|
|
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
|
-
##
|
|
14
|
+
## Agent model
|
|
15
15
|
|
|
16
16
|
### Discovery and addressing
|
|
17
17
|
|
|
18
|
-
- `talk-list-
|
|
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
|
|
33
|
-
- An unknown or invisible target is refused with `Unknown
|
|
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-
|
|
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:
|
|
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
|
-
-
|
|
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
|
|
50
|
-
|
|
|
51
|
-
| `talk-list-
|
|
52
|
-
| `talk-send`
|
|
53
|
-
| `talk-ask`
|
|
54
|
-
| `talk-reply`
|
|
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-
|
|
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
|
|
61
|
+
### Split work between agents
|
|
61
62
|
|
|
62
|
-
1. `talk-list-
|
|
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
|
|
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
|
|
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-
|
|
79
|
+
### Cross-agent review
|
|
79
80
|
|
|
80
|
-
- Ask another
|
|
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-
|
|
95
|
-
- **Respect status**: asking an offline
|
|
96
|
-
- **Visibility boundary**: you can only collaborate with
|
|
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.
|
package/src/talk/storage.ts
CHANGED
|
@@ -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
|
|
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
|