@aeondave/pi-persona 1.17.3 → 1.17.4
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/AGENTS.md +44 -36
- package/CHANGELOG.md +76 -1
- package/README.md +23 -13
- package/docs/ARCHITECTURE.md +97 -37
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +25 -8
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +83 -19
- package/src/engine/async.ts +49 -10
- package/src/engine/child.ts +10 -2
- package/src/engine/inproc.ts +87 -46
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/engine.ts +20 -21
- package/src/extension/hooks.ts +952 -938
- package/src/extension/identity.ts +41 -75
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2963 -2873
- package/src/loader.ts +11 -3
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +122 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +44 -6
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/agent-overlay.ts +11 -3
- package/src/ui/agent-tree.ts +43 -1
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
package/src/bus/contact.ts
CHANGED
|
@@ -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
|
-
* - `
|
|
9
|
-
*
|
|
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,68 @@
|
|
|
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 { compactInlineText } from "../ui/presentation.ts";
|
|
23
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
22
24
|
import type { InProcessBus, MsgKind } from "./inproc.ts";
|
|
23
25
|
|
|
24
26
|
const ContactParams = Type.Object({
|
|
25
27
|
kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
|
|
26
28
|
description:
|
|
27
|
-
"progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question.
|
|
29
|
+
"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
30
|
}),
|
|
29
31
|
message: Type.String({ description: "What to tell or ask the supervisor — specific and self-contained." }),
|
|
30
32
|
});
|
|
31
33
|
|
|
32
|
-
type
|
|
34
|
+
type ContactOutcome = "reported" | "answered" | "unavailable" | "timed-out" | "cancelled" | "failed";
|
|
35
|
+
|
|
36
|
+
type ContactDetails = {
|
|
37
|
+
kind: MsgKind;
|
|
38
|
+
/** Delivery evidence only, never evidence of a reply or approval. False means delivery was not confirmed. */
|
|
39
|
+
delivered: boolean;
|
|
40
|
+
outcome: ContactOutcome;
|
|
41
|
+
};
|
|
33
42
|
|
|
34
43
|
type AskFailure = "aborted" | "timeout" | "inbox-full" | "unavailable" | "generic";
|
|
35
44
|
|
|
45
|
+
const UNRESOLVED_NEXT_STEP = "Continue only with independent work already authorized; otherwise return a blocked/partial result.";
|
|
46
|
+
|
|
47
|
+
function unresolvedGuidance(kind: "decision" | "interview"): string {
|
|
48
|
+
return `The ${kind} remains unresolved. ${UNRESOLVED_NEXT_STEP}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function outcomeForAskFailure(failure: AskFailure): ContactOutcome {
|
|
52
|
+
switch (failure) {
|
|
53
|
+
case "aborted": return "cancelled";
|
|
54
|
+
case "timeout": return "timed-out";
|
|
55
|
+
case "inbox-full":
|
|
56
|
+
case "unavailable": return "unavailable";
|
|
57
|
+
case "generic": return "failed";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function askFailureText(failure: AskFailure): string {
|
|
62
|
+
switch (failure) {
|
|
63
|
+
case "aborted": return "The request was cancelled before the supervisor replied.";
|
|
64
|
+
case "timeout": return "The supervisor did not reply in time.";
|
|
65
|
+
case "inbox-full": return "The supervisor inbox is full; this request was not delivered. Retry later or consolidate the question.";
|
|
66
|
+
case "unavailable": return "The supervisor is unavailable; delivery could not be confirmed.";
|
|
67
|
+
case "generic": return "The supervisor request failed; delivery could not be confirmed.";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
function classifyAskFailure(error: unknown, signal: AbortSignal | undefined): AskFailure {
|
|
37
72
|
if (signal?.aborted) return "aborted";
|
|
38
73
|
const message = error instanceof Error ? error.message : "";
|
|
39
74
|
if (/^ask timeout(?:\s|$)/i.test(message)) return "timeout";
|
|
40
75
|
if (/^inbox full(?:\s|:|$)/i.test(message)) return "inbox-full";
|
|
41
|
-
if (
|
|
76
|
+
if (
|
|
77
|
+
/^ask unregistered(?:\s|$)/i.test(message) ||
|
|
78
|
+
/^unknown peer(?:\s|:|$)/i.test(message) ||
|
|
79
|
+
/^bridge: not connected(?:\s|$)/i.test(message) ||
|
|
80
|
+
/^broker (?:connection|client) closed(?:\s|$)/i.test(message)
|
|
81
|
+
) return "unavailable";
|
|
42
82
|
return "generic";
|
|
43
83
|
}
|
|
44
84
|
|
|
@@ -52,7 +92,7 @@ function result(t: string, details: ContactDetails): { content: [{ type: "text";
|
|
|
52
92
|
*
|
|
53
93
|
* `allowBlocking` (async runs only): when false, a `decision`/`interview` does NOT block —
|
|
54
94
|
* the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
|
|
55
|
-
*
|
|
95
|
+
* returns unresolved. This prevents a sync delegate/council from deadlocking on a child ask. */
|
|
56
96
|
export function makeContactSupervisorTool(
|
|
57
97
|
bus: InProcessBus,
|
|
58
98
|
fromHandle: string,
|
|
@@ -68,32 +108,49 @@ export function makeContactSupervisorTool(
|
|
|
68
108
|
"Use `progress` to report a milestone (one-way, don't wait).",
|
|
69
109
|
"Use `decision` when you hit a choice only the supervisor should make, or `interview` to ask a clarifying question.",
|
|
70
110
|
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;
|
|
111
|
+
? "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."
|
|
112
|
+
: "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
113
|
`Messages longer than ${MAX_CONTACT_MESSAGE_CHARS} characters are shortened before delivery; use smaller messages to preserve full detail.`,
|
|
74
114
|
].join(" "),
|
|
75
115
|
promptSnippet: "contact_supervisor — report progress or ask the supervisor a question",
|
|
76
116
|
parameters: ContactParams,
|
|
77
117
|
async execute(_toolCallId, params: Static<typeof ContactParams>, signal, _onUpdate, _ctx) {
|
|
78
118
|
const kind = params.kind as MsgKind;
|
|
119
|
+
if (signal?.aborted) {
|
|
120
|
+
const text = kind === "progress"
|
|
121
|
+
? "The progress update was cancelled before delivery."
|
|
122
|
+
: `The request was cancelled before the supervisor was contacted. ${unresolvedGuidance(kind)}`;
|
|
123
|
+
return result(text, { kind, delivered: false, outcome: "cancelled" });
|
|
124
|
+
}
|
|
79
125
|
const message = boundContactMessage(params.message);
|
|
80
126
|
if (kind === "progress") {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
127
|
+
try {
|
|
128
|
+
const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
|
|
129
|
+
return result(
|
|
130
|
+
delivered ? "Progress reported to the supervisor." : "(no supervisor listening or progress delivery could not be confirmed; progress dropped)",
|
|
131
|
+
{ kind, delivered, outcome: delivered ? "reported" : "unavailable" },
|
|
132
|
+
);
|
|
133
|
+
} catch {
|
|
134
|
+
return result("(progress could not be sent; delivery could not be confirmed)", { kind, delivered: false, outcome: "failed" });
|
|
135
|
+
}
|
|
86
136
|
}
|
|
87
137
|
|
|
88
138
|
// In a sync run the supervisor is blocked holding the turn → it cannot answer. Post the
|
|
89
|
-
// question one-way
|
|
139
|
+
// question one-way, but return unresolved; the send is not authorization to proceed on it.
|
|
90
140
|
if (!allowBlocking) {
|
|
91
|
-
|
|
141
|
+
let delivered: boolean;
|
|
142
|
+
try {
|
|
143
|
+
delivered = bus.send(fromHandle, supervisorHandle, message, kind);
|
|
144
|
+
} catch {
|
|
145
|
+
return result(
|
|
146
|
+
`The question could not be sent; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
|
|
147
|
+
{ kind, delivered: false, outcome: "failed" },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const delivery = delivered ? "question sent one-way" : "question delivery could not be confirmed";
|
|
92
151
|
return result(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
: "(no supervisor reachable — proceed using your best judgement)",
|
|
96
|
-
{ kind, delivered },
|
|
152
|
+
`The supervisor is busy right now; ${delivery}, and no answer is possible in this path. ${unresolvedGuidance(kind)}`,
|
|
153
|
+
{ kind, delivered, outcome: "unavailable" },
|
|
97
154
|
);
|
|
98
155
|
}
|
|
99
156
|
|
|
@@ -103,30 +160,48 @@ export function makeContactSupervisorTool(
|
|
|
103
160
|
let answer: Promise<string>;
|
|
104
161
|
try {
|
|
105
162
|
answer = bus.ask(fromHandle, supervisorHandle, message, signal ? { kind, signal } : { kind });
|
|
106
|
-
} catch {
|
|
107
|
-
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const failure = classifyAskFailure(error, signal);
|
|
165
|
+
return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
|
|
166
|
+
kind,
|
|
167
|
+
delivered: false,
|
|
168
|
+
outcome: outcomeForAskFailure(failure),
|
|
169
|
+
});
|
|
108
170
|
}
|
|
109
171
|
try {
|
|
110
172
|
const reply = await answer;
|
|
111
|
-
|
|
173
|
+
// A response that settles concurrently with cancellation is stale for this call:
|
|
174
|
+
// never report it as an answer once the caller's signal has aborted.
|
|
175
|
+
if (signal?.aborted) {
|
|
176
|
+
return result(`${askFailureText("aborted")} ${unresolvedGuidance(kind)}`, {
|
|
177
|
+
kind,
|
|
178
|
+
delivered: false,
|
|
179
|
+
outcome: "cancelled",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return result(`Supervisor: ${reply}`, { kind, delivered: true, outcome: "answered" });
|
|
112
183
|
} catch (error) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 });
|
|
184
|
+
const failure = classifyAskFailure(error, signal);
|
|
185
|
+
return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
|
|
186
|
+
kind,
|
|
187
|
+
delivered: false,
|
|
188
|
+
outcome: outcomeForAskFailure(failure),
|
|
189
|
+
});
|
|
128
190
|
}
|
|
129
191
|
},
|
|
192
|
+
// Compact purpose line only: the message is the child's own prose, and echoing it in the
|
|
193
|
+
// call card would overflow the transcript the way the host fallback's raw-args echo does.
|
|
194
|
+
renderCall(args, theme) {
|
|
195
|
+
const kind = compactInlineText(args.kind ?? "", { maxChars: 12 });
|
|
196
|
+
return new Text(theme.fg("toolTitle", theme.bold(kind ? `contact ${kind}` : "contact")), 0, 0);
|
|
197
|
+
},
|
|
198
|
+
// One shared literal receipt. An answered ask is bounded collapsed and expands to the full
|
|
199
|
+
// reply; progress receipts are naturally one compact line. Unresolved outcomes
|
|
200
|
+
// (unavailable/timed-out/cancelled/failed) are SUCCESSFUL tool results carrying an
|
|
201
|
+
// unresolved outcome — the error heading appears only when the host context says isError.
|
|
202
|
+
renderResult(result, { expanded }, theme, context) {
|
|
203
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
204
|
+
},
|
|
130
205
|
});
|
|
131
206
|
}
|
|
132
207
|
/** Maximum body accepted by contact_supervisor's message action. Full reports should use the
|
package/src/bus/peers.ts
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
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
|
|
|
25
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
26
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
24
27
|
import type { InProcessBus } from "./inproc.ts";
|
|
25
28
|
|
|
26
29
|
/** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
|
|
@@ -110,5 +113,17 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
|
|
|
110
113
|
{ action: "send", delivered },
|
|
111
114
|
);
|
|
112
115
|
},
|
|
116
|
+
// Compact purpose line only — same rule as contact_supervisor: no note body in the call
|
|
117
|
+
// card (the host fallback would echo raw args).
|
|
118
|
+
renderCall(args, theme) {
|
|
119
|
+
const action = compactInlineText(args.action ?? "", { maxChars: 12 });
|
|
120
|
+
return new Text(theme.fg("toolTitle", theme.bold(action ? `peer ${action}` : "peer")), 0, 0);
|
|
121
|
+
},
|
|
122
|
+
// One shared literal receipt: a send receipt is one compact line; a long peer list stays
|
|
123
|
+
// bounded collapsed (expansion hint) and shows every peer expanded. The error heading comes
|
|
124
|
+
// only from the host's isError flag, never from the semantic content.
|
|
125
|
+
renderResult(result, { expanded }, theme, context) {
|
|
126
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
127
|
+
},
|
|
113
128
|
});
|
|
114
129
|
}
|
|
@@ -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
|
-
?
|
|
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
|
package/src/core/capabilities.ts
CHANGED
|
@@ -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,57 @@
|
|
|
1
|
+
/** Per-leg effort selection. Pi alone owns model-capability clamping. Pure, no host imports. */
|
|
2
|
+
import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** An explicit invalid selection is a configuration error, never an instruction to inherit. */
|
|
5
|
+
export function parseLegThinking(value: unknown, label = "thinking"): ThinkingLevel | undefined {
|
|
6
|
+
if (value === undefined) return undefined;
|
|
7
|
+
if (!isThinkingLevel(value)) throw new Error(`${label} must be one of: ${THINKING_LEVELS.join(", ")}`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One shared display formatter for effort on delegate launch cards: the FULL level name,
|
|
12
|
+
* with `off` rendering nothing so absence is unambiguous. Unknown input renders nothing too
|
|
13
|
+
* (render paths must never throw); the compact siglas live in the tree/overlay view, not here. */
|
|
14
|
+
const EFFORT_FULL_NAMES: Record<ThinkingLevel, string> = {
|
|
15
|
+
off: "",
|
|
16
|
+
minimal: "minimal",
|
|
17
|
+
low: "low",
|
|
18
|
+
medium: "medium",
|
|
19
|
+
high: "high",
|
|
20
|
+
xhigh: "xhigh",
|
|
21
|
+
max: "max",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function effortFullName(level: ThinkingLevel): string {
|
|
25
|
+
return EFFORT_FULL_NAMES[level] ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The COMPACT siglas for the agent-tree/overlay rows (frozen convention): minimal→mn, low→l,
|
|
29
|
+
* medium→m, high→h, xhigh→xh, max→mx — with `off` rendering nothing so absence is unambiguous.
|
|
30
|
+
* Unknown input renders nothing too (render paths must never throw). The delegate CARDS keep the
|
|
31
|
+
* full names via {@link effortFullName}; the F9 drill-in uses those full names through the same
|
|
32
|
+
* formatter — only the tree/overlay ROWS are compact. */
|
|
33
|
+
const EFFORT_SIGLAS: Record<ThinkingLevel, string> = {
|
|
34
|
+
off: "",
|
|
35
|
+
minimal: "mn",
|
|
36
|
+
low: "l",
|
|
37
|
+
medium: "m",
|
|
38
|
+
high: "h",
|
|
39
|
+
xhigh: "xh",
|
|
40
|
+
max: "mx",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function effortSigla(level: ThinkingLevel): string {
|
|
44
|
+
return EFFORT_SIGLAS[level] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Explicit spec (including a roster member) > agent default > configured/inherited child effort.
|
|
48
|
+
* Validate explicit fields even when another field wins; never normalize a typo into inheritance. */
|
|
49
|
+
export function resolveLegThinking(
|
|
50
|
+
spec: { thinking?: ThinkingLevel },
|
|
51
|
+
agent: { thinking?: ThinkingLevel },
|
|
52
|
+
childThinking: unknown,
|
|
53
|
+
): ThinkingLevel | undefined {
|
|
54
|
+
const explicit = parseLegThinking(spec.thinking);
|
|
55
|
+
const configured = parseLegThinking(agent.thinking, "agent.thinking");
|
|
56
|
+
return explicit ?? configured ?? (isThinkingLevel(childThinking) ? childThinking : undefined);
|
|
57
|
+
}
|
package/src/core/ownership.ts
CHANGED
|
@@ -77,8 +77,7 @@ export function writeSetPathError(path: string): string | undefined {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Return an actionable error for a parallel write-set collision; undefined means safe to spawn.
|
|
80
|
-
* Lives here
|
|
81
|
-
* module — `delegate.ts` re-exports it unchanged for its own call sites. */
|
|
80
|
+
* Lives here so strategies and tools can depend on ownership without importing each other. */
|
|
82
81
|
export function validateParallelWriteSets(tasks: readonly WriteSetOwner[]): string | undefined {
|
|
83
82
|
for (const [index, task] of tasks.entries()) {
|
|
84
83
|
for (const path of task.writeSet ?? []) {
|
package/src/core/pi-compat.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { dirname, join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
export const MIN_PI_VERSION = "1.
|
|
5
|
+
/** API baseline of the current stable Pi target. Update with the four pinned development
|
|
6
|
+
* packages and qualify the installed artifact; historical host releases are not maintained.
|
|
7
|
+
* Keep README.md aligned. Host peers remain "*" because Pi supplies them through its loader. */
|
|
8
|
+
export const MIN_PI_VERSION = "1.4.0";
|
|
10
9
|
|
|
11
10
|
function parse(v: string): [number, number, number] | undefined {
|
|
12
11
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
@@ -21,15 +20,9 @@ export function satisfiesFloor(version: string, floor: string): boolean {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
function resolveHostEntry(): string | undefined {
|
|
24
|
-
// The host
|
|
25
|
-
// ERR_PACKAGE_PATH_NOT_EXPORTED for it — `import.meta.resolve` is tried first because it
|
|
26
|
-
// follows an `exports` map with no `main`/`require` condition. `createRequire` stays as a
|
|
27
|
-
// fallback for any host build that does publish one.
|
|
23
|
+
// The current host exposes its entry through the ESM import condition.
|
|
28
24
|
try {
|
|
29
25
|
return fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
30
|
-
} catch { /* fall through */ }
|
|
31
|
-
try {
|
|
32
|
-
return createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent");
|
|
33
26
|
} catch { /* unresolved host */ }
|
|
34
27
|
return undefined;
|
|
35
28
|
}
|
package/src/core/session-runs.ts
CHANGED
|
@@ -83,5 +83,5 @@ export function restoreNotice(lost: LostWork): string | undefined {
|
|
|
83
83
|
if (lost.monitors > 0) parts.push(plural(lost.monitors, "monitor"));
|
|
84
84
|
if (parts.length === 0) return undefined;
|
|
85
85
|
return `Session restored. Background work from before the restore did not survive (it lives only in memory): ${parts.join("; ")}. `
|
|
86
|
-
+ "
|
|
86
|
+
+ "Runtime workers and live run handles are not restored. Retained terminal reports may still be available via intercom { action:'results' } and result { ref:'result:<UUID>' }; read saved data without restarting work. Do not wait for vanished workers; relaunch unfinished work or re-arm wake sources only when still needed.";
|
|
87
87
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { Permission };
|
|
|
14
14
|
* new level here too — `pi.getThinkingLevel()` returns the upstream union, so a missing member here
|
|
15
15
|
* is a hard compile error where the host reads it (see `PersonaHost.getThinkingLevel`). */
|
|
16
16
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
17
|
-
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
18
18
|
|
|
19
19
|
export function isThinkingLevel(x: unknown): x is ThinkingLevel {
|
|
20
20
|
return typeof x === "string" && (THINKING_LEVELS as readonly string[]).includes(x);
|