@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
|
@@ -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
|
+
}
|
package/src/tools/exocom.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|