@aeondave/pi-persona 1.17.3 → 1.17.5

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.
Files changed (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -1,19 +1,34 @@
1
1
  /**
2
2
  * Slimmed wire-frame catalog + validators (spec B6) — comtac's protocol trimmed to the
3
3
  * frames pi-persona actually needs: register/send/cancel/reply/list/bye (client→host) and
4
- * registered/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
4
+ * registered/sent/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
5
5
  * `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
6
6
  * case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
7
7
  *
8
+ * Authenticated result_read/result_read_reply/result_read_cancel frames extend the same
9
+ * connection with a closed bounded page contract. No archive summaries or client grants.
8
10
  * Pure module: no Pi imports, unit-tested.
9
11
  */
10
12
 
11
13
  import type { MsgKind } from "../inproc.ts";
14
+ import { closedRecord, isReadReply, isReadRequest, type ReadReply, type ResultReadRequest } from "../result-reader.ts";
15
+
16
+ export function isReaderToken(value: unknown): value is string {
17
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
18
+ }
19
+
20
+ function isRequestId(value: unknown): value is string {
21
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
22
+ }
12
23
 
13
24
  export type Frame =
14
- | { t: "register"; handle: string; label?: string; group?: string; peers?: boolean }
15
- | { t: "registered"; handle: string }
25
+ | { t: "register"; handle: string; label?: string; group?: string; peers?: boolean; readerToken?: string }
26
+ | { t: "registered"; handle: string; readerToken?: string }
27
+ | ({ t: "result_read"; reqId: string } & ResultReadRequest)
28
+ | { t: "result_read_reply"; reqId: string; result: ReadReply }
29
+ | { t: "result_read_cancel"; reqId: string }
16
30
  | { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
31
+ | { t: "sent"; msgId: string; delivered: boolean }
17
32
  | { t: "cancel"; msgId: string }
18
33
  | { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
19
34
  | { t: "reply"; askId: string; text: string }
@@ -39,14 +54,27 @@ function isPeerEntry(value: unknown): value is { handle: string; label: string }
39
54
  export function isFrame(value: unknown): value is Frame {
40
55
  if (!isRecord(value)) return false;
41
56
  switch (value.t) {
57
+ case "result_read": {
58
+ if (!closedRecord(value, ["t", "reqId", "ref", "offset", "limit"]) || !isRequestId(value.reqId)) return false;
59
+ const { t: _t, reqId: _id, ...request } = value;
60
+ return isReadRequest(request);
61
+ }
62
+ case "result_read_reply":
63
+ return closedRecord(value, ["t", "reqId", "result"]) && isRequestId(value.reqId) && isReadReply(value.result);
64
+ case "result_read_cancel":
65
+ return closedRecord(value, ["t", "reqId"]) && isRequestId(value.reqId);
42
66
  case "register": {
43
- if (typeof value.handle !== "string") return false;
67
+ if (!closedRecord(value, ["t", "handle", "label", "group", "peers", "readerToken"])) return false;
68
+ if ("readerToken" in value && !isReaderToken(value.readerToken)) return false;
69
+ if (typeof value.handle !== "string" || value.handle.length === 0 || value.handle.length > 256) return false;
44
70
  if (value.label !== undefined && typeof value.label !== "string") return false;
45
71
  if (value.group !== undefined && typeof value.group !== "string") return false;
46
72
  return value.peers === undefined || typeof value.peers === "boolean";
47
73
  }
48
74
  case "registered":
49
- return typeof value.handle === "string";
75
+ return closedRecord(value, ["t", "handle", "readerToken"]) && typeof value.handle === "string"
76
+ && value.handle.length > 0 && value.handle.length <= 256
77
+ && (!("readerToken" in value) || isReaderToken(value.readerToken));
50
78
  case "send":
51
79
  return (
52
80
  typeof value.to === "string" &&
@@ -57,6 +85,8 @@ export function isFrame(value: unknown): value is Frame {
57
85
  );
58
86
  case "cancel":
59
87
  return typeof value.msgId === "string";
88
+ case "sent":
89
+ return closedRecord(value, ["t", "msgId", "delivered"]) && typeof value.msgId === "string" && typeof value.delivered === "boolean";
60
90
  case "deliver": {
61
91
  if (typeof value.from !== "string") return false;
62
92
  if (value.fromLabel !== undefined && typeof value.fromLabel !== "string") return false;
@@ -0,0 +1,8 @@
1
+ import type { InProcessBus, MsgKind } from "./inproc.ts";
2
+
3
+ /** Child contact tools need delivery admission, not a concrete mailbox implementation.
4
+ * A remote send awaits only the host's bounded receipt, never the recipient's answer. */
5
+ export interface ContactTransport {
6
+ send(from: string, to: string, text: string, kind?: MsgKind, signal?: AbortSignal): boolean | Promise<boolean>;
7
+ ask: InProcessBus["ask"];
8
+ }
@@ -5,9 +5,8 @@
5
5
  * `customTool` by the engine, bound to the shared {@link InProcessBus} + the child's
6
6
  * handle.
7
7
  *
8
- * - `progress_update` (one-way): post and return immediately — the supervisor reads it
9
- * on its next peek (or it rides back on the run's result in sync mode).
10
- * - `need_decision` / `interview_request` (blocking): `bus.ask` — the child waits for
8
+ * - `progress` (one-way): post and return immediately.
9
+ * - `decision` / `interview` (blocking): `bus.ask` — the child waits for
11
10
  * the supervisor's reply. This only makes sense in **async** runs where the supervisor
12
11
  * is free to answer; the tool aborts cleanly on the run's signal so it never hangs a
13
12
  * sync run with no one listening.
@@ -18,27 +17,69 @@
18
17
  import { type Static, Type } from "typebox";
19
18
 
20
19
  import { defineTool } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
21
21
 
22
- import type { InProcessBus, MsgKind } from "./inproc.ts";
22
+ import { compactInlineText } from "../ui/presentation.ts";
23
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
24
+ import type { MsgKind } from "./inproc.ts";
25
+ import type { ContactTransport } from "./contact-transport.ts";
23
26
 
24
27
  const ContactParams = Type.Object({
25
28
  kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
26
29
  description:
27
- "progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. For decision/interview, async runs wait for a reply; synchronous runs post one-way and continue.",
30
+ "progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. Async decision/interview requests wait for a reply; synchronous requests post one-way with no possible answer, so the request remains unresolved.",
28
31
  }),
29
32
  message: Type.String({ description: "What to tell or ask the supervisor — specific and self-contained." }),
30
33
  });
31
34
 
32
- type ContactDetails = { kind: MsgKind; delivered: boolean };
35
+ type ContactOutcome = "reported" | "answered" | "unavailable" | "timed-out" | "cancelled" | "failed";
36
+
37
+ type ContactDetails = {
38
+ kind: MsgKind;
39
+ /** Delivery evidence only, never evidence of a reply or approval. False means delivery was not confirmed. */
40
+ delivered: boolean;
41
+ outcome: ContactOutcome;
42
+ };
33
43
 
34
44
  type AskFailure = "aborted" | "timeout" | "inbox-full" | "unavailable" | "generic";
35
45
 
46
+ const UNRESOLVED_NEXT_STEP = "Continue only with independent work already authorized; otherwise return a blocked/partial result.";
47
+
48
+ function unresolvedGuidance(kind: "decision" | "interview"): string {
49
+ return `The ${kind} remains unresolved. ${UNRESOLVED_NEXT_STEP}`;
50
+ }
51
+
52
+ function outcomeForAskFailure(failure: AskFailure): ContactOutcome {
53
+ switch (failure) {
54
+ case "aborted": return "cancelled";
55
+ case "timeout": return "timed-out";
56
+ case "inbox-full":
57
+ case "unavailable": return "unavailable";
58
+ case "generic": return "failed";
59
+ }
60
+ }
61
+
62
+ function askFailureText(failure: AskFailure): string {
63
+ switch (failure) {
64
+ case "aborted": return "The request was cancelled before the supervisor replied.";
65
+ case "timeout": return "The supervisor did not reply in time.";
66
+ case "inbox-full": return "The supervisor inbox is full; this request was not delivered. Retry later or consolidate the question.";
67
+ case "unavailable": return "The supervisor is unavailable; delivery could not be confirmed.";
68
+ case "generic": return "The supervisor request failed; delivery could not be confirmed.";
69
+ }
70
+ }
71
+
36
72
  function classifyAskFailure(error: unknown, signal: AbortSignal | undefined): AskFailure {
37
73
  if (signal?.aborted) return "aborted";
38
74
  const message = error instanceof Error ? error.message : "";
39
75
  if (/^ask timeout(?:\s|$)/i.test(message)) return "timeout";
40
76
  if (/^inbox full(?:\s|:|$)/i.test(message)) return "inbox-full";
41
- if (/^ask unregistered(?:\s|$)/i.test(message) || /^broker (?:connection|client) closed(?:\s|$)/i.test(message)) return "unavailable";
77
+ if (
78
+ /^ask unregistered(?:\s|$)/i.test(message) ||
79
+ /^unknown peer(?:\s|:|$)/i.test(message) ||
80
+ /^bridge: not connected(?:\s|$)/i.test(message) ||
81
+ /^broker (?:connection|client) closed(?:\s|$)/i.test(message)
82
+ ) return "unavailable";
42
83
  return "generic";
43
84
  }
44
85
 
@@ -52,9 +93,9 @@ function result(t: string, details: ContactDetails): { content: [{ type: "text";
52
93
  *
53
94
  * `allowBlocking` (async runs only): when false, a `decision`/`interview` does NOT block —
54
95
  * the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
55
- * the child proceeds. This prevents a sync delegate/council from deadlocking on a child ask. */
96
+ * returns unresolved. This prevents a sync delegate/council from deadlocking on a child ask. */
56
97
  export function makeContactSupervisorTool(
57
- bus: InProcessBus,
98
+ bus: ContactTransport,
58
99
  fromHandle: string,
59
100
  supervisorHandle: string,
60
101
  opts: { allowBlocking?: boolean } = {},
@@ -68,32 +109,51 @@ export function makeContactSupervisorTool(
68
109
  "Use `progress` to report a milestone (one-way, don't wait).",
69
110
  "Use `decision` when you hit a choice only the supervisor should make, or `interview` to ask a clarifying question.",
70
111
  allowBlocking
71
- ? "This async run waits for the supervisor's reply before continuing."
72
- : "This synchronous run cannot block: decision/interview are posted one-way and return immediately; proceed using your best judgement.",
112
+ ? "This async run waits for the supervisor's reply before continuing. If there is no reply, the request remains unresolved: continue only with independent work already authorized, otherwise return a blocked/partial result."
113
+ : "This synchronous run cannot block: decision/interview are posted one-way and return immediately; no answer is possible in this path, so the request remains unresolved. Continue only with independent work already authorized, otherwise return a blocked/partial result.",
73
114
  `Messages longer than ${MAX_CONTACT_MESSAGE_CHARS} characters are shortened before delivery; use smaller messages to preserve full detail.`,
74
115
  ].join(" "),
75
116
  promptSnippet: "contact_supervisor — report progress or ask the supervisor a question",
76
117
  parameters: ContactParams,
77
118
  async execute(_toolCallId, params: Static<typeof ContactParams>, signal, _onUpdate, _ctx) {
78
119
  const kind = params.kind as MsgKind;
120
+ if (signal?.aborted) {
121
+ const text = kind === "progress"
122
+ ? "The progress update was cancelled before delivery."
123
+ : `The request was cancelled before the supervisor was contacted. ${unresolvedGuidance(kind)}`;
124
+ return result(text, { kind, delivered: false, outcome: "cancelled" });
125
+ }
79
126
  const message = boundContactMessage(params.message);
80
127
  if (kind === "progress") {
81
- const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
82
- return result(
83
- delivered ? "Progress reported to the supervisor." : "(no supervisor listening; progress dropped)",
84
- { kind, delivered },
85
- );
128
+ try {
129
+ const delivered = await bus.send(fromHandle, supervisorHandle, message, "progress", signal);
130
+ if (signal?.aborted) return result("The progress update was cancelled; delivery could not be confirmed.", { kind, delivered: false, outcome: "cancelled" });
131
+ return result(
132
+ delivered ? "Progress reported to the supervisor." : "Progress delivery could not be confirmed. The supervisor may already have received the update.",
133
+ { kind, delivered, outcome: delivered ? "reported" : "unavailable" },
134
+ );
135
+ } catch {
136
+ return result("Progress delivery could not be confirmed. The supervisor may already have received the update.", { kind, delivered: false, outcome: "failed" });
137
+ }
86
138
  }
87
139
 
88
140
  // In a sync run the supervisor is blocked holding the turn → it cannot answer. Post the
89
- // question one-way and let the child proceed, rather than deadlock until timeout.
141
+ // question one-way, but return unresolved; the send is not authorization to proceed on it.
90
142
  if (!allowBlocking) {
91
- const delivered = bus.send(fromHandle, supervisorHandle, message, kind);
143
+ let delivered: boolean;
144
+ try {
145
+ delivered = await bus.send(fromHandle, supervisorHandle, message, kind, signal);
146
+ } catch {
147
+ return result(
148
+ `Delivery of the question could not be confirmed. The supervisor may already have received it; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
149
+ { kind, delivered: false, outcome: "failed" },
150
+ );
151
+ }
152
+ if (signal?.aborted) return result(`The request was cancelled. ${unresolvedGuidance(kind)}`, { kind, delivered: false, outcome: "cancelled" });
153
+ const delivery = delivered ? "question sent one-way" : "question delivery could not be confirmed";
92
154
  return result(
93
- delivered
94
- ? "The supervisor is busy right now; your question was noted — proceed using your best judgement."
95
- : "(no supervisor reachable — proceed using your best judgement)",
96
- { kind, delivered },
155
+ `The supervisor is busy right now; ${delivery}, and no answer is possible in this path. ${unresolvedGuidance(kind)}`,
156
+ { kind, delivered, outcome: "unavailable" },
97
157
  );
98
158
  }
99
159
 
@@ -103,30 +163,48 @@ export function makeContactSupervisorTool(
103
163
  let answer: Promise<string>;
104
164
  try {
105
165
  answer = bus.ask(fromHandle, supervisorHandle, message, signal ? { kind, signal } : { kind });
106
- } catch {
107
- return result("(no supervisor reachable — proceed using your best judgement)", { kind, delivered: false });
166
+ } catch (error) {
167
+ const failure = classifyAskFailure(error, signal);
168
+ return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
169
+ kind,
170
+ delivered: false,
171
+ outcome: outcomeForAskFailure(failure),
172
+ });
108
173
  }
109
174
  try {
110
175
  const reply = await answer;
111
- return result(`Supervisor: ${reply}`, { kind, delivered: true });
176
+ // A response that settles concurrently with cancellation is stale for this call:
177
+ // never report it as an answer once the caller's signal has aborted.
178
+ if (signal?.aborted) {
179
+ return result(`${askFailureText("aborted")} ${unresolvedGuidance(kind)}`, {
180
+ kind,
181
+ delivered: false,
182
+ outcome: "cancelled",
183
+ });
184
+ }
185
+ return result(`Supervisor: ${reply}`, { kind, delivered: true, outcome: "answered" });
112
186
  } catch (error) {
113
- const msg = (() => {
114
- switch (classifyAskFailure(error, signal)) {
115
- case "aborted":
116
- return "(the request was cancelled before the supervisor replied)";
117
- case "timeout":
118
- return "(the supervisor did not reply in time — proceed using your best judgement)";
119
- case "inbox-full":
120
- return "(the supervisor inbox is full — this request was not delivered; retry later or consolidate the question)";
121
- case "unavailable":
122
- return "(the supervisor is no longer available — this request was not delivered; proceed using your best judgement)";
123
- default:
124
- return "(the supervisor request could not be completed — proceed using your best judgement)";
125
- }
126
- })();
127
- return result(msg, { kind, delivered: false });
187
+ const failure = classifyAskFailure(error, signal);
188
+ return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
189
+ kind,
190
+ delivered: false,
191
+ outcome: outcomeForAskFailure(failure),
192
+ });
128
193
  }
129
194
  },
195
+ // Compact purpose line only: the message is the child's own prose, and echoing it in the
196
+ // call card would overflow the transcript the way the host fallback's raw-args echo does.
197
+ renderCall(args, theme) {
198
+ const kind = compactInlineText(args.kind ?? "", { maxChars: 12 });
199
+ return new Text(theme.fg("toolTitle", theme.bold(kind ? `contact ${kind}` : "contact")), 0, 0);
200
+ },
201
+ // One shared literal receipt. An answered ask is bounded collapsed and expands to the full
202
+ // reply; progress receipts are naturally one compact line. Unresolved outcomes
203
+ // (unavailable/timed-out/cancelled/failed) are SUCCESSFUL tool results carrying an
204
+ // unresolved outcome — the error heading appears only when the host context says isError.
205
+ renderResult(result, { expanded }, theme, context) {
206
+ return renderLiteralToolResult(result, { expanded }, theme, context);
207
+ },
130
208
  });
131
209
  }
132
210
  /** Maximum body accepted by contact_supervisor's message action. Full reports should use the
package/src/bus/peers.ts CHANGED
@@ -20,8 +20,11 @@
20
20
  import { type Static, Type } from "typebox";
21
21
 
22
22
  import { defineTool } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
23
24
 
24
- import type { InProcessBus } from "./inproc.ts";
25
+ import { compactInlineText } from "../ui/presentation.ts";
26
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
27
+ import type { ContactTransport } from "./contact-transport.ts";
25
28
 
26
29
  /** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
27
30
  export const MAX_PEER_SENDS = 20;
@@ -56,7 +59,7 @@ function result(t: string, details: PeerDetails): { content: [{ type: "text"; te
56
59
 
57
60
  /** Build the child's `contact_peer` tool. Same `defineTool` variance shim as
58
61
  * `makeContactSupervisorTool`, so it slots into `customTools: ToolDefinition[]`. */
59
- export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps: ContactPeerDeps) {
62
+ export function makeContactPeerTool(bus: Pick<ContactTransport, "send">, selfHandle: string, deps: ContactPeerDeps) {
60
63
  const maxSends = deps.maxSends ?? MAX_PEER_SENDS;
61
64
  let sent = 0;
62
65
  return defineTool({
@@ -71,7 +74,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
71
74
  ].join(" "),
72
75
  promptSnippet: "contact_peer — list your run's sibling agents or send one a one-way note",
73
76
  parameters: PeerParams,
74
- async execute(_toolCallId, params: Static<typeof PeerParams>, _signal, _onUpdate, _ctx) {
77
+ async execute(_toolCallId, params: Static<typeof PeerParams>, signal, _onUpdate, _ctx) {
75
78
  if (params.action === "list") {
76
79
  const peers = await deps.listPeers();
77
80
  return result(
@@ -84,6 +87,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
84
87
  if (!params.to || params.message === undefined) {
85
88
  return result("contact_peer send needs { to, message } — use `list` for peer handles.", { action: "send", delivered: false });
86
89
  }
90
+ if (signal?.aborted) return result("Peer message cancelled before delivery.", { action: "send", delivered: false });
87
91
  if (params.message.length > MAX_PEER_MESSAGE_CHARS) {
88
92
  return result(
89
93
  `contact_peer message exceeds the ${MAX_PEER_MESSAGE_CHARS}-character limit; send a smaller note.`,
@@ -99,16 +103,29 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
99
103
  sent += 1;
100
104
  // Enforce the run-isolation boundary (previously only convention): a handle that is
101
105
  // not one of THIS run's peers (e.g. "supervisor", or another run's child) never gets
102
- // bus.send called at all — same "gone" wording as an actually-finished peer, so the
106
+ // bus.send called at all — same unavailable wording as an actually-finished peer, so the
103
107
  // tool result gives no signal either way about what exists outside this run's scope.
104
108
  const inScope = (await deps.listPeers()).some((p) => p.handle === params.to);
105
- const delivered = inScope && bus.send(selfHandle, params.to, params.message, "progress");
109
+ const accepted = inScope && !signal?.aborted && await bus.send(selfHandle, params.to, params.message, "progress", signal);
110
+ const delivered = accepted && !signal?.aborted;
106
111
  return result(
107
112
  delivered
108
113
  ? `Sent to ${params.to}.`
109
- : `Peer "${params.to}" is gone — it may have already finalized. Proceed with your own judgement.`,
114
+ : `Delivery to peer "${params.to}" could not be confirmed — it may be unavailable or its inbox full. Proceed with your own judgement.`,
110
115
  { action: "send", delivered },
111
116
  );
112
117
  },
118
+ // Compact purpose line only — same rule as contact_supervisor: no note body in the call
119
+ // card (the host fallback would echo raw args).
120
+ renderCall(args, theme) {
121
+ const action = compactInlineText(args.action ?? "", { maxChars: 12 });
122
+ return new Text(theme.fg("toolTitle", theme.bold(action ? `peer ${action}` : "peer")), 0, 0);
123
+ },
124
+ // One shared literal receipt: a send receipt is one compact line; a long peer list stays
125
+ // bounded collapsed (expansion hint) and shows every peer expanded. The error heading comes
126
+ // only from the host's isError flag, never from the semantic content.
127
+ renderResult(result, { expanded }, theme, context) {
128
+ return renderLiteralToolResult(result, { expanded }, theme, context);
129
+ },
113
130
  });
114
131
  }
@@ -0,0 +1,88 @@
1
+ /** Narrow result-read boundary. No archive ownership, discovery, paths or session identity.
2
+ * The parent supplies a reader that checks CURRENT capabilities/owner and its explicit ref
3
+ * grant on every call. The broker owns attachment, cancellation and wire validation only. */
4
+ import { isResultRef } from "../results/record.ts";
5
+ import { RESULT_READ_DEFAULT, RESULT_READ_MAX, type ResultReadRequest, type ResultProvenance,
6
+ type TerminalStatus } from "../results/types.ts";
7
+
8
+ export type { ResultReadRequest } from "../results/types.ts";
9
+ export const RESULT_READ_TIMEOUT_MS = 10_000;
10
+ export const RESULT_READ_MAX_PENDING = 8;
11
+ export const RESULT_READ_MAX_FRAME_BYTES = 128 * 1024;
12
+ /** Bound replay tracking without ever evicting an id and accepting its replay. Exhaustion
13
+ * ends the reader connection; it does not reconnect or retry. */
14
+ export const RESULT_READ_MAX_REQUESTS = 4096;
15
+
16
+ const ERROR_MESSAGES = {
17
+ invalid: "Invalid result read request.",
18
+ denied: "Result read is not authorized.",
19
+ revoked: "Result reader was revoked.",
20
+ unavailable: "Result reader is unavailable.",
21
+ cancelled: "Result read was cancelled.",
22
+ timeout: "Result read deadline exceeded.",
23
+ busy: "Too many pending result reads.",
24
+ protocol: "Invalid result reader protocol.",
25
+ } as const;
26
+ export type ReadErrorCode = keyof typeof ERROR_MESSAGES;
27
+ export type ReadReply = {
28
+ ok: true; ref: string; body: string; offset: number; nextOffset?: number;
29
+ storedChars: number; originalChars: number; truncated: boolean; endOfStoredBody: boolean;
30
+ status: TerminalStatus; provenance: ResultProvenance;
31
+ } | { ok: false; code: ReadErrorCode; message: string };
32
+
33
+ export interface ScopedReader {
34
+ read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
35
+ revoke(): void;
36
+ }
37
+
38
+ export interface PreparedResultReader {
39
+ /** Private spawn environment only. Never put in tasks, receipts or diagnostics. */
40
+ token: string;
41
+ revoke(): void;
42
+ /** Historical attachment evidence, not a promise of continued connectivity. */
43
+ wasBound(): boolean;
44
+ }
45
+
46
+ export function readFailure(code: ReadErrorCode): ReadReply & { ok: false } {
47
+ return { ok: false, code, message: ERROR_MESSAGES[code] };
48
+ }
49
+
50
+ /** Reject hidden/accessor fields too: this validator is also used on parent callback data. */
51
+ export function closedRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
53
+ const proto: unknown = Object.getPrototypeOf(value);
54
+ return (proto === Object.prototype || proto === null) && Reflect.ownKeys(value).every((key) => {
55
+ const d = Object.getOwnPropertyDescriptor(value, key);
56
+ return typeof key === "string" && keys.includes(key) && d?.enumerable === true && "value" in d;
57
+ });
58
+ }
59
+ const counter = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
60
+
61
+ export function isReadRequest(value: unknown): value is ResultReadRequest {
62
+ return closedRecord(value, ["ref", "offset", "limit"]) && isResultRef(value.ref)
63
+ && (!("offset" in value) || counter(value.offset))
64
+ && (!("limit" in value) || (counter(value.limit) && value.limit >= 2 && value.limit <= RESULT_READ_MAX));
65
+ }
66
+
67
+ export function isReadReply(value: unknown): value is ReadReply {
68
+ if (!closedRecord(value, ["ok", "ref", "body", "offset", "nextOffset", "storedChars", "originalChars",
69
+ "truncated", "endOfStoredBody", "status", "provenance", "code", "message"])) return false;
70
+ if (value.ok === false) return closedRecord(value, ["ok", "code", "message"])
71
+ && typeof value.code === "string" && Object.hasOwn(ERROR_MESSAGES, value.code)
72
+ && value.message === ERROR_MESSAGES[value.code as ReadErrorCode];
73
+ if (value.ok !== true || "code" in value || "message" in value || !isResultRef(value.ref)
74
+ || typeof value.body !== "string" || value.body.length > RESULT_READ_MAX || !counter(value.offset)
75
+ || !counter(value.storedChars) || !counter(value.originalChars) || value.originalChars < value.storedChars
76
+ || value.truncated !== (value.originalChars > value.storedChars)
77
+ || (value.status !== "done" && value.status !== "failed" && value.status !== "stopped")
78
+ || (value.provenance !== "result-output" && value.provenance !== "partial-output" && value.provenance !== "error")) return false;
79
+ const end = value.offset + value.body.length;
80
+ if (!Number.isSafeInteger(end) || end > value.storedChars || value.endOfStoredBody !== (end === value.storedChars)) return false;
81
+ return value.endOfStoredBody ? !("nextOffset" in value) : value.body.length > 0 && value.nextOffset === end;
82
+ }
83
+
84
+ /** Page offsets are UTF-16; the archive, not transport, owns surrogate-boundary slicing. */
85
+ export function isReadReplyFor(value: unknown, request: ResultReadRequest): value is ReadReply {
86
+ return isReadReply(value) && (!value.ok || (value.ref === request.ref && value.offset === (request.offset ?? 0)
87
+ && value.body.length <= (request.limit ?? RESULT_READ_DEFAULT)));
88
+ }
package/src/core/brief.ts CHANGED
@@ -265,7 +265,11 @@ export interface ExocomBriefTools {
265
265
  wait: boolean;
266
266
  release: boolean;
267
267
  progress: boolean;
268
- status: boolean;
268
+ status: boolean;
269
+ task?: boolean;
270
+ offer?: boolean;
271
+ publish?: boolean;
272
+ profile?: boolean;
269
273
  }
270
274
 
271
275
  /** The two runtime facts the peer brief may not assume, because both are false in reachable
@@ -391,7 +395,11 @@ export function buildExocomBrief(peers: ExocomPeerBrief[], input: ExocomBriefInp
391
395
  : input.joined === true
392
396
  ? `External-workspace ledger: repository-relative claims belong to the joined workspace, not your home files, so this instance cannot claim its home paths.${ask}${close}${input.tools.progress ? " Progress notes remain available." : ""}${input.tools.release ? " Release of your outbound asks remains available." : ""}${status}${postcard}${evidence}`
393
397
  : `Repository claims are unavailable to this persona in this workspace; do not attempt repository claims.${ask}${close}${outboundLifecycle}${status}${postcard}${evidence}`;
394
- lines.push(ledger);
398
+ lines.push(ledger);
399
+ if (input.tools.task) lines.push("Shared objectives between independent colleagues: exocom_task({ action: \"list\" }) discovers bounded tasks. Create a clearly scoped task, then let one peer voluntarily take it; pickup reserves its declared write set atomically. Finish with an explicit done/abandoned outcome. Stale tasks are not permission to take over. Each peer remains responsible to its own user; task ownership grants no supervisor authority.");
400
+ if (input.tools.offer) lines.push("exocom_offer sends an optional invitation with an expiry, never an obligation to answer or permission to act. Use it for voluntary collaboration; reserve exocom_ask for a blocking coordination question.");
401
+ if (input.tools.publish) lines.push("exocom_publish shares an explicit bounded evidence excerpt and lets peers retrieve it by message id. Verify attributed evidence; a result archive reference never gives another session access to your archive.");
402
+ if (input.tools.profile) lines.push("exocom_profile advertises self-reported capability tags and availability for discovery. These are not permissions, verified skills or a guarantee of availability; unknown stays unknown.");
395
403
  // The line above bounds WHETHER to open a thread; this one bounds how long it stays open. The
396
404
  // stop condition is DRIFT, never a round count: back-and-forth is often how a hard point gets
397
405
  // settled — what wastes tokens is a round that stopped serving the request, which a counter
@@ -406,8 +414,10 @@ export function buildExocomBrief(peers: ExocomPeerBrief[], input: ExocomBriefInp
406
414
  // "ask your human" has no addressee headless, where the silence is the answer, not permission.
407
415
  // The hand-off is the other half of the partition the line above opens: peer ⇒ judgement you
408
416
  // cannot write down, sub-agent ⇒ work you can.
409
- const handoff = input.canDelegate
410
- ? " Work you can specify (measure X, test Y) goes to a sub-agent, which reports back instead of conversing."
417
+ const handoff = input.canDelegate
418
+ ? input.tools.task
419
+ ? " Use a sub-agent for supervised work you own; use voluntary shared tasks when independent colleagues agree to divide a common objective."
420
+ : " Work you can specify (measure X, test Y) goes to a sub-agent, which reports back instead of conversing."
411
421
  : "";
412
422
  // The stop ACTION must be performable on an inbound-driven turn too: "decide it yourself" has no
413
423
  // object you own when the work is the peer's question, and escalating someone else's question
@@ -80,7 +80,7 @@ export function canFanOut(caps: EffectiveCapabilities, delegateTool = DEFAULT_DE
80
80
  return caps.tools.has(delegateTool);
81
81
  }
82
82
 
83
- export const EXOCOM_TOOL_NAMES = ["exocom_list", "exocom_send", "exocom_name", "exocom_claim", "exocom_ask", "exocom_answer", "exocom_decline", "exocom_wait", "exocom_release", "exocom_progress", "exocom_status"] as const;
83
+ export const EXOCOM_TOOL_NAMES = ["exocom_list", "exocom_send", "exocom_name", "exocom_claim", "exocom_ask", "exocom_answer", "exocom_decline", "exocom_wait", "exocom_release", "exocom_progress", "exocom_status", "exocom_task", "exocom_offer", "exocom_publish", "exocom_profile"] as const;
84
84
  const EXOCOM_TOOLS: ReadonlySet<string> = new Set(EXOCOM_TOOL_NAMES);
85
85
 
86
86
  export function canCallTool(caps: EffectiveCapabilities, toolName: string): boolean {
@@ -0,0 +1,64 @@
1
+ /** Public local companion contracts. These describe loaded capabilities and UI activity, not authority. */
2
+ export const COMPANION_EVENT = "pi-persona:companion:v1";
3
+ export const ACTIVITY_EVENT = "pi-persona:activity:v1";
4
+ export const MEMORY_OWNER_EVENT = "pi-persona:memory-owner:v1";
5
+ export const MEMORY_OWNER_CHANGED_EVENT = "pi-persona:memory-owner-changed:v1";
6
+ export const MEMORY_PERSONA_ENV = "PI_PERSONA_MEMORY_PERSONA";
7
+
8
+ export interface CompanionMemoryOwner {
9
+ version: 1;
10
+ provider: "pi-persona";
11
+ sessionId: string;
12
+ ready: boolean;
13
+ persona: string | null;
14
+ }
15
+
16
+ /** Spawn-local inheritance only. Absent/invalid is unavailable, never a global-settings fallback. */
17
+ export function inheritedMemoryPersona(raw: string | undefined): { persona: string | null } | undefined {
18
+ if (raw === undefined || raw.length > 8_192) return undefined;
19
+ try {
20
+ const value = JSON.parse(raw) as Record<string, unknown> | null;
21
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1 || Object.keys(value).some((key) => key !== "version" && key !== "persona")) return undefined;
22
+ if (value.persona !== null && (typeof value.persona !== "string" || !value.persona.trim() || value.persona.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(value.persona))) return undefined;
23
+ return { persona: value.persona as string | null };
24
+ } catch { return undefined; }
25
+ }
26
+
27
+ export type CompanionRole = "supervisor" | "leg" | "disabled";
28
+ export interface CompanionPresence {
29
+ version: 1;
30
+ provider: "pi-persona";
31
+ role: CompanionRole;
32
+ capabilities: { memory: 1 | false; activity: 1 | false };
33
+ }
34
+ export interface CompanionActivity {
35
+ version: 1;
36
+ producerId: "pi-persona-mind";
37
+ activityId: string;
38
+ sessionId: string;
39
+ phase: "inspect" | "stage" | "apply";
40
+ state: "running" | "staged" | "done" | "failed";
41
+ target: "project" | "project-long" | "persona" | "shared";
42
+ }
43
+
44
+ export function companionPresence(role: CompanionRole): CompanionPresence {
45
+ return { version: 1, provider: "pi-persona", role, capabilities: { memory: role === "disabled" ? false : 1, activity: role === "supervisor" ? 1 : false } };
46
+ }
47
+
48
+ /** Closed records cannot accidentally export raw memory, model claims, or prompts into the tree. */
49
+ export function parseCompanionActivity(value: unknown): CompanionActivity | undefined {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
51
+ const prototype = Object.getPrototypeOf(value);
52
+ if (prototype !== null && prototype !== Object.prototype) return undefined;
53
+ const allowed = new Set(["version", "producerId", "activityId", "sessionId", "phase", "state", "target"]);
54
+ if (Reflect.ownKeys(value).some((key) => typeof key !== "string" || !allowed.has(key))) return undefined;
55
+ const activity = value as Record<string, unknown>;
56
+ if (activity.version !== 1 || activity.producerId !== "pi-persona-mind") return undefined;
57
+ if (typeof activity.activityId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/.test(activity.activityId)) return undefined;
58
+ if (typeof activity.sessionId !== "string" || activity.sessionId.length < 1 || activity.sessionId.length > 256 || /[\s\u0000-\u001f\u007f]/u.test(activity.sessionId)) return undefined;
59
+ if (activity.phase !== "inspect" && activity.phase !== "stage" && activity.phase !== "apply") return undefined;
60
+ if (activity.state !== "running" && activity.state !== "staged" && activity.state !== "done" && activity.state !== "failed") return undefined;
61
+ if (activity.state === "staged" && activity.phase !== "stage") return undefined;
62
+ if (activity.target !== "project" && activity.target !== "project-long" && activity.target !== "persona" && activity.target !== "shared") return undefined;
63
+ return { version: 1, producerId: "pi-persona-mind", activityId: activity.activityId, sessionId: activity.sessionId, phase: activity.phase, state: activity.state, target: activity.target };
64
+ }