@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.
Files changed (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  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/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -0,0 +1,315 @@
1
+ /** Task, offer, and evidence adapters for the external Exocom collaboration ledger. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { type Static } from "typebox";
4
+
5
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Text } from "@earendil-works/pi-tui";
7
+
8
+ import { attributePeer } from "../core/fence.ts";
9
+ import { collaborationOfferStatus, collaborationStatus } from "../exocom/collaboration.ts";
10
+ import type { ExocomPlane } from "../exocom/plane.ts";
11
+ import type { ExocomSemanticFrame } from "../exocom/envelope.ts";
12
+ import type { LedgerState } from "../exocom/ledger.ts";
13
+ import { normalizeMetadataText } from "../exocom/registry.ts";
14
+ import { renderWriteSet, untrusted } from "../exocom/untrusted.ts";
15
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
16
+ import { createReportBody } from "../ui/report-body.ts";
17
+ import {
18
+ OfferParams,
19
+ parseOfferInput,
20
+ parsePublishInput,
21
+ parseTaskInput,
22
+ PublishParams,
23
+ TaskParams,
24
+ } from "./exocom-collaboration-input.ts";
25
+
26
+ export interface ExocomCollaborationDeps {
27
+ getPlane: () => ExocomPlane | undefined;
28
+ sessionId: () => string;
29
+ name: () => string;
30
+ now: () => number;
31
+ /** False for joined foreign workspaces whose file paths differ from the shared ledger scope. */
32
+ canClaim: () => boolean;
33
+ /** Recheck the effective capability at execution time, including targeted tool denies. */
34
+ canCallTool: (name: string) => boolean;
35
+ /** Canonicalize a public exocom_list target to its authenticated raw session id. */
36
+ resolveTarget: (target: string) => string;
37
+ dispatch: (frame: ExocomSemanticFrame) => Promise<{ msg_id: string; peerWakeDeferred?: true }>;
38
+ ledger: () => LedgerState;
39
+ /** Resolve attribution from the live registry; self-reported frame names are never used. */
40
+ labelFor: (sessionId: string) => string | undefined;
41
+ /** Exact public routing target shown by exocom_list for a live session. */
42
+ targetForSession: (sessionId: string) => string | undefined;
43
+ }
44
+
45
+ function toolResult(text: string, details: Record<string, unknown>) {
46
+ return { content: [{ type: "text" as const, text }], details };
47
+ }
48
+ function checkedIdentity(deps: ExocomCollaborationDeps) {
49
+ if (!deps.getPlane()) throw new Error("exocom is not active for this persona");
50
+ const now = deps.now();
51
+ if (!Number.isFinite(now) || !Number.isFinite(new Date(now).getTime())) throw new Error("exocom: clock returned an invalid timestamp");
52
+ return { from_session: deps.sessionId(), from_name: deps.name(), ts: new Date(now).toISOString(), now };
53
+ }
54
+ function requireToolPermission(deps: ExocomCollaborationDeps, tool: string): void {
55
+ if (deps.canCallTool(tool) !== true) throw new Error(`${tool}: this persona cannot call this Exocom tool`);
56
+ }
57
+ function frameIdentity(identity: ReturnType<typeof checkedIdentity>) {
58
+ return { from_session: identity.from_session, from_name: identity.from_name, ts: identity.ts };
59
+ }
60
+ function requireWriteClaim(deps: ExocomCollaborationDeps, tool: string): void {
61
+ if (deps.canClaim() !== true || deps.canCallTool("exocom_claim") !== true) {
62
+ throw new Error(`${tool}: write-set tasks require local workspace scope and callable exocom_claim permission`);
63
+ }
64
+ }
65
+ function taskByKey(state: LedgerState, workKey: string) {
66
+ return state.collaboration?.tasks.find((task) => task.created.work_key === workKey);
67
+ }
68
+ function taskByIdentity(state: LedgerState, workKey: string, taskId: string) {
69
+ return state.collaboration?.tasks.find((task) => task.created.work_key === workKey && task.created.msg_id === taskId);
70
+ }
71
+ function dispatchNotice(deferred: boolean): string {
72
+ return deferred ? " · ledger committed; peer wake deferred" : "";
73
+ }
74
+ function dispatchDetails(deferred: boolean): Record<string, unknown> {
75
+ return deferred ? { peerWakeDeferred: true } : {};
76
+ }
77
+ function resolvePeerLabel(deps: ExocomCollaborationDeps, sessionId: string): string {
78
+ if (sessionId === deps.sessionId()) return `you (${untrusted(deps.name(), 48) || "self"})`;
79
+ return deps.labelFor(sessionId) ?? sessionId;
80
+ }
81
+ function listPage(deps: ExocomCollaborationDeps, offset: number, limit: number, detail: boolean) {
82
+ if (detail && limit > 5) throw new Error("exocom_task: limit must be at most 5 when detail=true");
83
+ const state = deps.ledger();
84
+ const tasks = state.collaboration?.tasks ?? [];
85
+ const offers = state.collaboration?.offers ?? [];
86
+ const total = tasks.length;
87
+ const page = tasks.slice(offset, offset + limit);
88
+ const lines = [`exocom tasks · ${total} retained · showing ${page.length} from offset ${offset}`];
89
+ for (const task of page) {
90
+ const created = task.created;
91
+ const status = collaborationStatus(task);
92
+ const creatorLabel = untrusted(resolvePeerLabel(deps, created.from_session), 64) || created.from_session;
93
+ lines.push(`\n${created.work_key} · task_id=${created.msg_id} · ${status} · created by ${creatorLabel} · ${created.write_set.length ? `${created.write_set.length} write paths` : "read-only"}`);
94
+ if (task.taken) {
95
+ const takerLabel = untrusted(resolvePeerLabel(deps, task.taken.from_session), 64) || task.taken.from_session;
96
+ lines.push(`taker: ${takerLabel}`);
97
+ }
98
+ if (created.write_set.length) {
99
+ lines.push(`write scope summary (may be clipped; use exocom_task get with work_key=${created.work_key} and task_id=${created.msg_id} for exact paths)`);
100
+ lines.push(attributePeer(resolvePeerLabel(deps, created.from_session), renderWriteSet(created.write_set)));
101
+ }
102
+ const taskOffers = offers.filter((offer) => offer.work_key === created.work_key && offer.task_id === created.msg_id);
103
+ for (const offer of taskOffers.slice(0, 3)) {
104
+ const target = offer.to_session
105
+ ? normalizeMetadataText(deps.targetForSession(offer.to_session) ?? `offline:${offer.to_session}`, 80, "offline target")
106
+ : "pool";
107
+ const offerState = collaborationOfferStatus(offer, tasks, deps.now());
108
+ lines.push(`offer ${offer.offer_id} → ${target} · ${offerState} · expires ${offer.expires_at}`);
109
+ }
110
+ if (taskOffers.length > 3) lines.push(`offers: +${taskOffers.length - 3} more`);
111
+ if (detail) {
112
+ lines.push(attributePeer(resolvePeerLabel(deps, created.from_session), created.description));
113
+ if (task.progress) lines.push(attributePeer(resolvePeerLabel(deps, task.progress.from_session), `Progress: ${task.progress.note}`));
114
+ if (task.finished) lines.push(attributePeer(resolvePeerLabel(deps, task.finished.from_session), `Finish (${task.finished.outcome}): ${task.finished.note}`));
115
+ }
116
+ }
117
+ const nextOffset = offset + page.length < total ? offset + page.length : undefined;
118
+ return toolResult(lines.join("\n"), {
119
+ action: "list", total, offset, limit, count: page.length,
120
+ task_ids: page.map((task) => task.created.msg_id),
121
+ ...(nextOffset === undefined ? {} : { nextOffset }), detail,
122
+ });
123
+ }
124
+
125
+ function taskDetail(deps: ExocomCollaborationDeps, state: LedgerState, task: NonNullable<ReturnType<typeof taskByIdentity>>) {
126
+ const created = task.created;
127
+ const status = collaborationStatus(task);
128
+ const creator = resolvePeerLabel(deps, created.from_session);
129
+ const lines = [
130
+ `exocom task · ${created.work_key} · task_id=${created.msg_id} · ${status}`,
131
+ `created by ${untrusted(creator, 64) || created.from_session}`,
132
+ attributePeer(creator, `Description:\n${created.description}`),
133
+ ];
134
+ if (created.write_set.length) {
135
+ lines.push(attributePeer(creator, `Exact write_set JSON (${created.write_set.length} paths):\n${JSON.stringify(created.write_set, null, 2)}`));
136
+ } else {
137
+ lines.push("write_set: [] (read-only)");
138
+ }
139
+ if (task.taken) lines.push(`taken by ${untrusted(resolvePeerLabel(deps, task.taken.from_session), 64) || task.taken.from_session}`);
140
+ if (task.progress) lines.push(attributePeer(resolvePeerLabel(deps, task.progress.from_session), `Progress:\n${task.progress.note}`));
141
+ if (task.finished) lines.push(attributePeer(resolvePeerLabel(deps, task.finished.from_session), `Finish (${task.finished.outcome}):\n${task.finished.note}`));
142
+ const offers = (state.collaboration?.offers ?? []).filter((offer) => offer.work_key === created.work_key && offer.task_id === created.msg_id);
143
+ for (const offer of offers.slice(0, 3)) {
144
+ const target = offer.to_session
145
+ ? normalizeMetadataText(deps.targetForSession(offer.to_session) ?? `offline:${offer.to_session}`, 80, "offline target")
146
+ : "pool";
147
+ lines.push(`offer ${offer.offer_id} → ${target} · ${collaborationOfferStatus(offer, state.collaboration?.tasks ?? [], deps.now())} · expires ${offer.expires_at}`);
148
+ }
149
+ if (offers.length > 3) lines.push(`offers: +${offers.length - 3} more`);
150
+ return toolResult(lines.join("\n\n"), {
151
+ action: "get", work_key: created.work_key, task_id: created.msg_id, status,
152
+ writeSetCount: created.write_set.length, offerIds: offers.map((offer) => offer.offer_id),
153
+ });
154
+ }
155
+
156
+ export function registerExocomCollaborationTools(pi: ExtensionAPI, deps: ExocomCollaborationDeps): void {
157
+ pi.registerTool({
158
+ name: "exocom_task",
159
+ label: "Exocom Task",
160
+ description: "Create or pick up a bounded collaboration task, report progress or finish, inspect full details with get by work_key+task_id, and page through retained tasks. For discovery, use list without work_key/task_id. List pages default to 10 rows (5 with detail=true), cap at 20 summaries and 5 detailed rows; explicit detail limits above 5 are rejected. List write scopes are summaries that may be clipped; get requires both identifiers returned by create/list and shows the exact fenced scope. Write-set tasks require workspace-scoped exocom_claim permission; a foreign-workspace Pi may take read-only tasks.",
161
+ parameters: TaskParams,
162
+ async execute(_id, raw: Static<typeof TaskParams>) {
163
+ const params = parseTaskInput(raw);
164
+ requireToolPermission(deps, "exocom_task");
165
+ if (params.action === "list") {
166
+ checkedIdentity(deps);
167
+ return listPage(deps, params.offset, params.limit, params.detail);
168
+ }
169
+ const identity = checkedIdentity(deps);
170
+ if (params.action === "create") {
171
+ if (params.write_set.length) requireWriteClaim(deps, "exocom_task create");
172
+ if (taskByKey(deps.ledger(), params.work_key)) throw new Error(`exocom_task: work_key ${params.work_key} already exists`);
173
+ const frame: ExocomSemanticFrame = {
174
+ kind: "task_create", ...frameIdentity(identity), work_key: params.work_key,
175
+ description: params.description, write_set: params.write_set, msg_id: randomUUID(),
176
+ };
177
+ const sent = await deps.dispatch(frame);
178
+ return toolResult(`exocom: created task ${params.work_key} · task_id=${sent.msg_id}${dispatchNotice(sent.peerWakeDeferred === true)}`, {
179
+ action: "create", work_key: params.work_key, task_id: sent.msg_id, msg_id: sent.msg_id, writeSetCount: params.write_set.length,
180
+ ...dispatchDetails(sent.peerWakeDeferred === true),
181
+ });
182
+ }
183
+ const state = deps.ledger();
184
+ const task = taskByIdentity(state, params.work_key, params.task_id);
185
+ if (!task) throw new Error(`exocom_task: task_id ${params.task_id} does not match retained task ${params.work_key}`);
186
+ if (params.action === "get") return taskDetail(deps, state, task);
187
+ if (params.action === "take") {
188
+ if (task.created.write_set.length) requireWriteClaim(deps, "exocom_task take");
189
+ if (collaborationStatus(task) !== "open") throw new Error(`exocom_task: task is ${collaborationStatus(task)}; pickup refused`);
190
+ const frame: ExocomSemanticFrame = { kind: "task_take", ...frameIdentity(identity), work_key: params.work_key, task_id: params.task_id, msg_id: randomUUID() };
191
+ const sent = await deps.dispatch(frame);
192
+ return toolResult(`exocom: took task ${params.work_key} · task_id=${params.task_id} · msg_id=${sent.msg_id}${dispatchNotice(sent.peerWakeDeferred === true)}`, {
193
+ action: "take", work_key: params.work_key, task_id: params.task_id, msg_id: sent.msg_id, ...dispatchDetails(sent.peerWakeDeferred === true),
194
+ });
195
+ }
196
+ if (task.taken?.from_session !== identity.from_session || collaborationStatus(task) !== "taken") {
197
+ throw new Error(`exocom_task ${params.action}: only the active task taker may report progress or finish`);
198
+ }
199
+ let frame: ExocomSemanticFrame;
200
+ if (params.action === "progress") {
201
+ frame = { kind: "progress", ...frameIdentity(identity), work_key: params.work_key, task_id: params.task_id, note: params.note, msg_id: randomUUID() };
202
+ } else {
203
+ frame = { kind: "task_finish", ...frameIdentity(identity), work_key: params.work_key, task_id: params.task_id, outcome: params.outcome, note: params.note, msg_id: randomUUID() };
204
+ }
205
+ const sent = await deps.dispatch(frame);
206
+ return toolResult(`exocom: ${params.action} ${params.work_key} · task_id=${params.task_id} · msg_id=${sent.msg_id}`, {
207
+ action: params.action, work_key: params.work_key, task_id: params.task_id, msg_id: sent.msg_id,
208
+ ...(params.action === "finish" ? { outcome: params.outcome } : {}),
209
+ ...dispatchDetails(sent.peerWakeDeferred === true),
210
+ });
211
+ },
212
+ renderCall(args, theme) {
213
+ return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Task "))}${theme.fg("accent", normalizeMetadataText(args.action, 24, "task"))}${args.work_key ? ` ${normalizeMetadataText(args.work_key, 80, "work")}` : ""}`, 0, 0);
214
+ },
215
+ renderResult(res, options, theme, context) {
216
+ if (options.expanded && !(context?.isError ?? res.isError) && (res.details?.detail === true || res.details?.action === "get")) {
217
+ const text = res.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
218
+ return createReportBody(text, theme, {}, context?.lastComponent);
219
+ }
220
+ return renderLiteralToolResult(res, options, theme, context);
221
+ },
222
+ });
223
+
224
+ pi.registerTool({
225
+ name: "exocom_offer",
226
+ label: "Exocom Offer",
227
+ description: "Offer an open task to one peer from exocom_list or to the pool. Expiration defaults to one hour and is capped at 24 hours.",
228
+ parameters: OfferParams,
229
+ async execute(_id, raw: Static<typeof OfferParams>) {
230
+ const params = parseOfferInput(raw);
231
+ requireToolPermission(deps, "exocom_offer");
232
+ const identity = checkedIdentity(deps);
233
+ const task = taskByIdentity(deps.ledger(), params.work_key, params.task_id);
234
+ if (!task) throw new Error(`exocom_offer: task_id ${params.task_id} does not match retained task ${params.work_key}`);
235
+ if (collaborationStatus(task) !== "open") throw new Error(`exocom_offer: offers require an open task, current status is ${collaborationStatus(task)}`);
236
+ let to_session: string | undefined;
237
+ if (params.target !== undefined) {
238
+ to_session = deps.resolveTarget(params.target);
239
+ if (to_session === identity.from_session) throw new Error("exocom_offer: target cannot be this session");
240
+ }
241
+ const offerId = randomUUID();
242
+ const expires_at = new Date(identity.now + params.expiresInMinutes * 60_000).toISOString();
243
+ const frame: ExocomSemanticFrame = {
244
+ kind: "offer", ...frameIdentity(identity), work_key: params.work_key, task_id: params.task_id, offer_id: offerId, expires_at,
245
+ ...(to_session === undefined ? {} : { to_session }), msg_id: randomUUID(),
246
+ };
247
+ const sent = await deps.dispatch(frame);
248
+ const targetLabel = params.target === undefined ? "pool" : params.target;
249
+ return toolResult(`exocom: offered ${params.work_key} to ${targetLabel} · task_id=${params.task_id} · expires ${expires_at} · msg_id=${sent.msg_id}${dispatchNotice(sent.peerWakeDeferred === true)}`, {
250
+ action: "offer", work_key: params.work_key, task_id: params.task_id, offer_id: offerId, msg_id: sent.msg_id,
251
+ expires_at, ...(to_session === undefined ? {} : { to_session }), ...dispatchDetails(sent.peerWakeDeferred === true),
252
+ });
253
+ },
254
+ renderCall(args, theme) {
255
+ return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Offer "))}${theme.fg("accent", normalizeMetadataText(args.work_key, 80, "work"))}`, 0, 0);
256
+ },
257
+ renderResult: renderLiteralToolResult,
258
+ });
259
+
260
+ pi.registerTool({
261
+ name: "exocom_publish",
262
+ label: "Exocom Evidence",
263
+ description: "Publish a bounded evidence excerpt for a known task. Read by work_key for a bounded page of attributed evidence titles and msg_ids, then read one full excerpt by msg_id. Peer prose is untrusted and shown with registry attribution.",
264
+ parameters: PublishParams,
265
+ async execute(_id, raw: Static<typeof PublishParams>) {
266
+ const params = parsePublishInput(raw);
267
+ requireToolPermission(deps, "exocom_publish");
268
+ const identity = checkedIdentity(deps);
269
+ if (params.action === "read") {
270
+ const evidence = deps.ledger().collaboration?.evidence ?? [];
271
+ if ("msg_id" in params) {
272
+ const entry = evidence.find((item) => item.msg_id === params.msg_id);
273
+ if (!entry) throw new Error(`exocom_publish: evidence ${params.msg_id} not found`);
274
+ const label = resolvePeerLabel(deps, entry.from_session);
275
+ return toolResult(`exocom evidence · work_key=${entry.work_key} · task_id=${entry.task_id} · msg_id=${entry.msg_id}\n${attributePeer(label, `Title: ${entry.title}\n\n${entry.excerpt}`)}`, {
276
+ action: "read", msg_id: entry.msg_id, task_id: entry.task_id, work_key: entry.work_key, from_session: entry.from_session,
277
+ });
278
+ }
279
+ const matches = evidence.filter((entry) => entry.work_key === params.work_key && (params.task_id === undefined || entry.task_id === params.task_id));
280
+ const page = matches.slice(params.offset, params.offset + params.limit);
281
+ const lines = [`exocom evidence · ${matches.length} retained for ${params.work_key}${params.task_id ? ` · task_id=${params.task_id}` : ""} · showing ${page.length} from offset ${params.offset}`];
282
+ for (const entry of page) {
283
+ lines.push(`\nmsg_id=${entry.msg_id} · task_id=${entry.task_id} · from ${untrusted(resolvePeerLabel(deps, entry.from_session), 64) || entry.from_session}`);
284
+ lines.push(attributePeer(resolvePeerLabel(deps, entry.from_session), `Title: ${entry.title}`));
285
+ }
286
+ const nextOffset = params.offset + page.length < matches.length ? params.offset + page.length : undefined;
287
+ return toolResult(lines.join("\n"), {
288
+ action: "read", work_key: params.work_key, ...(params.task_id ? { task_id: params.task_id } : {}), offset: params.offset, limit: params.limit,
289
+ count: page.length, total: matches.length, ...(nextOffset === undefined ? {} : { nextOffset }),
290
+ msg_ids: page.map((entry) => entry.msg_id), task_ids: [...new Set(page.map((entry) => entry.task_id))],
291
+ });
292
+ }
293
+ if (!taskByIdentity(deps.ledger(), params.work_key, params.task_id)) throw new Error(`exocom_publish: task_id ${params.task_id} does not match retained task ${params.work_key}`);
294
+ const frame: ExocomSemanticFrame = {
295
+ kind: "publish", ...frameIdentity(identity), work_key: params.work_key, task_id: params.task_id, title: params.title,
296
+ excerpt: params.excerpt, msg_id: randomUUID(),
297
+ };
298
+ const sent = await deps.dispatch(frame);
299
+ return toolResult(`exocom: published evidence for ${params.work_key} · task_id=${params.task_id} · msg_id=${sent.msg_id}${dispatchNotice(sent.peerWakeDeferred === true)}`, {
300
+ action: "publish", work_key: params.work_key, task_id: params.task_id, msg_id: sent.msg_id, ...dispatchDetails(sent.peerWakeDeferred === true),
301
+ });
302
+ },
303
+ renderCall(args, theme) {
304
+ const label = args.action === "read" ? normalizeMetadataText(args.msg_id ?? args.work_key, 80, "evidence") : normalizeMetadataText(args.work_key, 80, "work");
305
+ return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Evidence "))}${theme.fg("accent", label)}`, 0, 0);
306
+ },
307
+ renderResult(res, options, theme, context) {
308
+ if (options.expanded && !(context?.isError ?? res.isError) && res.details?.action === "read") {
309
+ const text = res.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
310
+ return createReportBody(text, theme, {}, context?.lastComponent);
311
+ }
312
+ return renderLiteralToolResult(res, options, theme, context);
313
+ },
314
+ });
315
+ }
@@ -0,0 +1,102 @@
1
+ /** Self-reported Exocom presence. This metadata never grants authority or changes tool gates. */
2
+ import { type Static, Type } from "typebox";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { normalizeExocomProfile, type ExocomProfile } from "../exocom/profile.ts";
6
+ import type { ExocomPlane } from "../exocom/plane.ts";
7
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
8
+
9
+ const ExocomProfileParams = Type.Object({
10
+ action: Type.Union([Type.Literal("get"), Type.Literal("set"), Type.Literal("clear")], {
11
+ description: "get the current self-report · set selected fields · clear it back to unknown",
12
+ }),
13
+ capabilities: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 48 }), {
14
+ maxItems: 8,
15
+ description: "set: up to 8 self-reported capability labels, each at most 48 characters",
16
+ })),
17
+ availability: Type.Optional(Type.Union([
18
+ Type.Literal("available"), Type.Literal("busy"), Type.Literal("away"),
19
+ ], { description: "set: your self-reported current availability; omitted remains unknown" })),
20
+ });
21
+
22
+ interface ProfileToolDetails {
23
+ action: "get" | "set" | "clear";
24
+ profile?: ExocomProfile;
25
+ }
26
+
27
+ function formatProfile(profile: ExocomProfile | undefined): string {
28
+ if (!profile) return "No profile reported; availability is unknown.";
29
+ const availability = profile.availability ?? "unknown";
30
+ const capabilities = profile.capabilities.length > 0 ? profile.capabilities.join(" · ") : "not reported";
31
+ return `Self-reported availability: ${availability}\nSelf-reported capabilities: ${capabilities}`;
32
+ }
33
+
34
+ function actionText(details: ProfileToolDetails): string {
35
+ if (details.action === "clear") return "Profile cleared; availability is unknown.";
36
+ return formatProfile(details.profile);
37
+ }
38
+
39
+ /** `getPlane` is read on each call so a revoked Exocom plane cannot leave a stale mutator alive. */
40
+ export function registerExocomProfileTool(pi: ExtensionAPI, getPlane: () => ExocomPlane | undefined): void {
41
+ pi.registerTool({
42
+ name: "exocom_profile",
43
+ label: "Exocom Profile",
44
+ description: [
45
+ "Report your own optional Exocom presence. `get` reads it, `set` updates the supplied fields,",
46
+ "and `clear` removes it. Capabilities and availability are self-reported metadata only; they",
47
+ "do not prove identity, grant permissions, reserve work, or change tool access. An unset",
48
+ "availability is unknown; a partial update leaves an existing value unchanged. The profile lasts only for this Exocom session.",
49
+ ].join(" "),
50
+ parameters: ExocomProfileParams,
51
+ async execute(_toolCallId, params: Static<typeof ExocomProfileParams>): Promise<{
52
+ content: [{ type: "text"; text: string }];
53
+ details: ProfileToolDetails;
54
+ }> {
55
+ const action = params && typeof params === "object" ? (params as { action?: unknown }).action : undefined;
56
+ if (typeof action !== "string" || !["get", "set", "clear"].includes(action)) {
57
+ throw new Error("exocom_profile: action must be get, set, or clear");
58
+ }
59
+ const plane = getPlane();
60
+ if (!plane) throw new Error("exocom is not active for this persona");
61
+ if (params.action === "get") {
62
+ const profile = normalizeExocomProfile(plane.getProfile());
63
+ return { content: [{ type: "text" as const, text: formatProfile(profile) }], details: { action: "get", ...(profile ? { profile } : {}) } satisfies ProfileToolDetails };
64
+ }
65
+ if (params.action === "clear") {
66
+ plane.setProfile(undefined);
67
+ return { content: [{ type: "text" as const, text: "Profile cleared; availability is unknown." }], details: { action: "clear" } satisfies ProfileToolDetails };
68
+ }
69
+ if (params.capabilities === undefined && params.availability === undefined) {
70
+ throw new Error("exocom_profile set needs at least one of { capabilities, availability }");
71
+ }
72
+ const current = normalizeExocomProfile(plane.getProfile());
73
+ const next = normalizeExocomProfile({
74
+ capabilities: params.capabilities ?? current?.capabilities ?? [],
75
+ ...(params.availability ?? current?.availability
76
+ ? { availability: params.availability ?? current?.availability }
77
+ : {}),
78
+ });
79
+ if (!next) throw new Error("exocom_profile set needs at least one visible capability or an availability value");
80
+ plane.setProfile(next);
81
+ return {
82
+ content: [{ type: "text" as const, text: formatProfile(next) }],
83
+ details: { action: "set", profile: next } satisfies ProfileToolDetails,
84
+ };
85
+ },
86
+ renderCall(args, theme) {
87
+ const action = typeof args.action === "string" ? args.action : "get";
88
+ return new Text(theme.fg("toolTitle", theme.bold(`Exocom Profile ${action}`)), 0, 0);
89
+ },
90
+ renderResult(result, { expanded }, theme, context) {
91
+ if (context?.isError || result.isError) return renderLiteralToolResult(result, { expanded }, theme, context);
92
+ const details = result.details as unknown as ProfileToolDetails | undefined;
93
+ if (!details || !["get", "set", "clear"].includes(details.action)) {
94
+ return renderLiteralToolResult(result, { expanded }, theme, { isError: true });
95
+ }
96
+ return renderLiteralToolResult({
97
+ ...result,
98
+ content: [{ type: "text", text: actionText(details) }],
99
+ }, { expanded }, theme);
100
+ },
101
+ });
102
+ }
@@ -25,6 +25,7 @@ import { fencePeer } from "../core/fence.ts";
25
25
  import { inventedExocomNameHint } from "../core/naming.ts";
26
26
  import { MAX_IDENTITY_NAME_CHARS } from "../core/session-identity.ts";
27
27
  import { ExocomPeerRejection, type DisplayPeer, type ExocomPlane } from "../exocom/plane.ts";
28
+ import { normalizeExocomProfile, type ExocomProfile } from "../exocom/profile.ts";
28
29
  import { normalizeMetadataText, normalizePeerName } from "../exocom/registry.ts";
29
30
  import type { ExocomScope } from "../exocom/scope.ts";
30
31
  import { compactInlineText } from "../ui/presentation.ts";
@@ -86,6 +87,7 @@ type ExocomPeerSummary = Pick<DisplayPeer, "displayName" | "persona" | "model" |
86
87
  workspace_code?: string;
87
88
  workspace_label?: string;
88
89
  sameWorkspace?: boolean;
90
+ profile?: ExocomProfile;
89
91
  };
90
92
 
91
93
  function peerWord(count: number): string {
@@ -96,6 +98,14 @@ function peerWord(count: number): string {
96
98
  * model so it can retain useful detail without turning one peer into a 300-column wall. */
97
99
  function peerRows(peer: ExocomPeerSummary, expanded: boolean): string[] {
98
100
  const context = `${Math.max(0, Math.min(100, Number(peer.context_pct) || 0))}%`;
101
+ const availability = peer.profile?.availability ?? "unknown";
102
+ const capabilities = peer.profile?.capabilities ?? [];
103
+ const visibleCapabilities = capabilities.slice(0, expanded ? 8 : 2).join(" · ")
104
+ || "capabilities not reported";
105
+ const omittedCapabilities = Math.max(0, capabilities.length - (expanded ? 8 : 2));
106
+ const profileLine = expanded
107
+ ? ` self-reported availability: ${availability} · capabilities: ${compactInlineText(visibleCapabilities, { maxChars: 420 })}${omittedCapabilities ? ` · +${omittedCapabilities} more` : ""}`
108
+ : ` self-reported: ${availability} · ${compactInlineText(visibleCapabilities, { maxChars: 44 })}${omittedCapabilities ? ` · +${omittedCapabilities} more` : ""}`;
99
109
  const workspace = peer.workspace_code && peer.workspace_label
100
110
  ? ` workspace: ${compactInlineText(peer.workspace_label, { maxChars: expanded ? 64 : 40 }) || "workspace"} [${peer.workspace_code}] · ${peer.sameWorkspace === false ? "external" : "same"}`
101
111
  : undefined;
@@ -103,12 +113,12 @@ function peerRows(peer: ExocomPeerSummary, expanded: boolean): string[] {
103
113
  const name = compactInlineText(peer.displayName, { maxChars: 48 }) || "peer";
104
114
  const persona = compactInlineText(peer.persona, { maxChars: 32 }) || "unknown";
105
115
  const model = compactInlineText(peer.model, { maxChars: 88 }) || "unknown";
106
- return [`- ${name} · ${persona} · ctx ${context}`, ` model: ${model}`, ...(workspace ? [workspace] : [])];
116
+ return [`- ${name} · ${persona} · ctx ${context}`, ` model: ${model}`, profileLine, ...(workspace ? [workspace] : [])];
107
117
  }
108
118
  const name = compactInlineText(peer.displayName, { maxChars: 24 }) || "peer";
109
119
  const persona = compactInlineText(peer.persona, { maxChars: 18 }) || "unknown";
110
120
  const model = compactInlineText(peer.model, { maxChars: 36 }) || "unknown";
111
- return [`- ${name} · ${persona} · ${model} · ctx ${context}`, ...(workspace ? [workspace] : [])];
121
+ return [`- ${name} · ${persona} · ${model} · ctx ${context}`, profileLine, ...(workspace ? [workspace] : [])];
112
122
  }
113
123
 
114
124
  /** The model-facing row carries the ROUTE as well as the human label: a display name is
@@ -118,10 +128,15 @@ function modelPeerRow(peer: ExocomPeerSummary): string {
118
128
  const workspace = peer.workspace_code && peer.workspace_label
119
129
  ? ` · workspace: ${normalizeMetadataText(peer.workspace_label, 80, "workspace")} [${peer.workspace_code}] · ${peer.sameWorkspace === false ? "external workspace" : "same workspace"}`
120
130
  : "";
121
- return `- ${normalizeMetadataText(peer.displayName, 80, "peer")} · ${normalizeMetadataText(peer.persona, 64, "unknown")} · ${normalizeMetadataText(peer.model, 160, "unknown")} · ctx ${Math.max(0, Math.min(100, Number(peer.context_pct) || 0))}% · target: ${normalizeMetadataText(peer.target, 80, "peer")}${workspace}`;
131
+ const availability = peer.profile?.availability ?? "unknown";
132
+ const capabilities = peer.profile?.capabilities.length
133
+ ? peer.profile.capabilities.join(", ")
134
+ : "not reported";
135
+ return `- ${normalizeMetadataText(peer.displayName, 80, "peer")} · ${normalizeMetadataText(peer.persona, 64, "unknown")} · ${normalizeMetadataText(peer.model, 160, "unknown")} · ctx ${Math.max(0, Math.min(100, Number(peer.context_pct) || 0))}% · target: ${normalizeMetadataText(peer.target, 80, "peer")} · self-reported availability: ${availability} · capabilities: ${capabilities}${workspace}`;
122
136
  }
123
137
 
124
138
  function summarizePeer(peer: DisplayPeer, scope: ExocomScope | undefined): ExocomPeerSummary {
139
+ const profile = normalizeExocomProfile(peer.profile);
125
140
  if (!scope) {
126
141
  return {
127
142
  displayName: normalizeMetadataText(peer.displayName, 80, "peer"),
@@ -129,6 +144,7 @@ function summarizePeer(peer: DisplayPeer, scope: ExocomScope | undefined): Exoco
129
144
  persona: normalizeMetadataText(peer.persona, 64, "unknown"),
130
145
  model: normalizeMetadataText(peer.model, 160, "unknown"),
131
146
  context_pct: Math.max(0, Math.min(100, Number(peer.context_pct) || 0)),
147
+ ...(profile ? { profile } : {}),
132
148
  };
133
149
  }
134
150
  // Legacy peers predate workspace metadata. Their registry location still proves they belong
@@ -147,6 +163,7 @@ function summarizePeer(peer: DisplayPeer, scope: ExocomScope | undefined): Exoco
147
163
  ...(workspaceCode ? { workspace_code: workspaceCode } : {}),
148
164
  workspace_label: normalizeMetadataText(workspaceLabel, 80, "workspace"),
149
165
  sameWorkspace: workspaceId === scope.homeWorkspaceId,
166
+ ...(profile ? { profile } : {}),
150
167
  };
151
168
  }
152
169
 
package/src/tools/flow.ts CHANGED
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Type } from "typebox";
4
4
  import { expandDetailHint, failureDetails, isInline } from "../extension/shared.ts";
5
5
  import { fenceUntrusted } from "../core/fence.ts";
6
+ import { resultReceiptHint } from "../results/retrieval.ts";
6
7
  import { flowDisposition, type FlowOutcome } from "../orchestration/flow-run.ts";
7
8
  import type { FlowSpec } from "../orchestration/flow.ts";
8
9
  import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "../ui/presentation.ts";
@@ -10,6 +11,7 @@ import { toolUsageField } from "../ui/usage.ts";
10
11
  import { sumUsage } from "../orchestration/reducers.ts";
11
12
  import { Text } from "@earendil-works/pi-tui";
12
13
  import { createReportBody } from "../ui/report-body.ts";
14
+ import { orchestrationLaunchLines, type OrchestrationLaunchSnapshot } from "../ui/orchestration-launch.ts";
13
15
  import type { AgentResult, FailureKind } from "../orchestration/types.ts";
14
16
  import { launchReceipt, type BackgroundRuns, type TrackedRun } from "./background-run.ts";
15
17
 
@@ -18,6 +20,7 @@ export interface FlowToolDeps {
18
20
  set lastCtx(value: ExtensionContext | undefined);
19
21
  loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
20
22
  listFlows(cwd: string): string[];
23
+ launchSnapshot(ctx: ExtensionContext, spec: FlowSpec): OrchestrationLaunchSnapshot;
21
24
  runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string, tracked?: TrackedRun): Promise<FlowOutcome>;
22
25
  /** Launch/join: a flow is one tracked run, like a delegate leg (see background-run.ts). */
23
26
  runs: BackgroundRuns;
@@ -78,7 +81,15 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
78
81
  isError: true,
79
82
  };
80
83
  }
84
+ let launch: OrchestrationLaunchSnapshot | undefined;
81
85
  try {
86
+ try {
87
+ const snapshot = d.launchSnapshot(ctx, parsed.flow);
88
+ launch = { ...snapshot, participants: snapshot.participants.map((participant) => ({ ...participant })) };
89
+ } catch {
90
+ // This is presentation metadata only; a settings/model lookup must not block the flow.
91
+ launch = { participants: [], unavailable: true };
92
+ }
82
93
  // The same launch `delegate` uses: ONE tracked run. Interactive sessions get a receipt now and
83
94
  // the outcome as ONE completion follow-up; a checkpoint is the OPERATOR's modal and never wakes
84
95
  // the model. Headless sessions join the run before returning (and reject every checkpoint).
@@ -93,7 +104,7 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
93
104
  const name = compactInlineText(parsed.flow.name, { maxChars: 64 });
94
105
  return {
95
106
  content: [{ type: "text", text: launchReceipt("flow", id, name, "when it ends; a checkpoint waits for the operator, not for you") }],
96
- details: { runId: id, flow: name },
107
+ details: { runId: id, flow: name, launch },
97
108
  isError: false,
98
109
  };
99
110
  }
@@ -102,26 +113,28 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
102
113
  const outcome = ran.outcome;
103
114
  if (!outcome) {
104
115
  const message = run?.error ?? run?.result?.error ?? run?.status ?? "the run record is gone";
105
- return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message }), isError: true };
116
+ return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message, launch }), isError: true };
106
117
  }
107
118
  const details = {
119
+ launch,
108
120
  ok: outcome.ok,
109
121
  failedPhase: outcome.failedPhase,
110
122
  failureKind: outcome.failureKind,
111
123
  error: outcome.error,
112
124
  stoppedAtGate: outcome.stoppedAtGate,
125
+ ...(run?.resultReceipt ? { resultReceipt: { ...run.resultReceipt } } : {}),
113
126
  };
114
127
  // An unapproved checkpoint is the operator's choice: the flow stopped, it did not fail.
115
128
  const failed = flowDisposition(outcome) === "failed";
116
129
  return {
117
- content: [{ type: "text", text: fenceUntrusted(outcome.output || "(flow produced no output)") }],
130
+ content: [{ type: "text", text: `${fenceUntrusted(outcome.output || "(flow produced no output)")}${run?.resultReceipt ? `\n\n${resultReceiptHint(run.resultReceipt)}` : ""}` }],
118
131
  details: failed ? failureDetails(details) : details,
119
132
  isError: failed,
120
133
  ...toolUsageField(usage),
121
134
  };
122
135
  } catch (err) {
123
136
  const message = err instanceof Error ? err.message : String(err);
124
- return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message }), isError: true };
137
+ return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message, ...(launch ? { launch } : {}) }), isError: true };
125
138
  }
126
139
  },
127
140
  renderCall(args, theme) {
@@ -136,6 +149,7 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
136
149
  stoppedAtGate?: string;
137
150
  runId?: string;
138
151
  flow?: string;
152
+ launch?: OrchestrationLaunchSnapshot;
139
153
  };
140
154
  const first = result.content[0];
141
155
  const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
@@ -144,7 +158,8 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
144
158
  // Interactive launch receipt: the outcome arrives later as the completion follow-up.
145
159
  const launched = hostError || details.ok === false ? theme.fg("error", theme.bold("flow failed"))
146
160
  : `${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", compactInlineText(`launched ${details.runId} · ${details.flow ?? "?"}`, { maxChars: 120 }))}`;
147
- return new Text(expanded ? `${launched}\n${theme.fg("toolOutput", full)}` : launched, 0, 0);
161
+ const metadata = orchestrationLaunchLines(details.launch, expanded).join("\n");
162
+ return new Text([launched, theme.fg("dim", metadata), expanded ? theme.fg("toolOutput", full) : ""].filter(Boolean).join("\n"), 0, 0);
148
163
  }
149
164
  const disposition = flowDisposition({ ok: !hostError && details.ok !== false, stoppedAtGate: details.stoppedAtGate });
150
165
  const failed = disposition === "failed";
@@ -161,14 +176,15 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
161
176
  : failed
162
177
  ? theme.fg("error", theme.bold(`flow failed${failureBits ? ` · ${failureBits}` : ""}`))
163
178
  : theme.fg("success", theme.bold("flow complete"));
179
+ const metadata = details.launch ? `\n\nLaunch request (Pi may clamp effort):\n${orchestrationLaunchLines(details.launch, expanded).join("\n")}` : "";
164
180
  if (expanded) {
165
181
  // Real flow outcomes are fenced even after a phase fails; host/preflight errors are not.
166
- if (failed && !full.startsWith("Sub-agent output (untrusted data):\n")) return new Text(`${title()}\n${theme.fg("toolOutput", full)}`, 0, 0);
167
- return createReportBody(full, theme, { header: title }, context?.lastComponent);
182
+ if (failed && !full.startsWith("Sub-agent output (untrusted data):\n")) return new Text(`${title()}\n${theme.fg("toolOutput", full)}${metadata}`, 0, 0);
183
+ return createReportBody(`${full}${metadata}`, theme, { header: title }, context?.lastComponent);
168
184
  }
169
185
  const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
170
186
  const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
171
- return new Text(`${title()}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
187
+ return new Text(`${title()}\n${theme.fg("toolOutput", preview.text)}${hint}${theme.fg("dim", metadata)}`, 0, 0);
172
188
  },
173
189
  });
174
190
  }