@aeondave/pi-persona 1.17.3 → 1.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- 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/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- 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/companion.ts +64 -0
- 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 +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- 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/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- 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/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- 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/ui/strategy-leg.ts +40 -0
|
@@ -5,12 +5,13 @@ import { failureDetails } from "../extension/shared.ts";
|
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
|
|
7
7
|
import { compactVisibleText } from "../ui/presentation.ts";
|
|
8
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
8
9
|
import { createReportBody } from "../ui/report-body.ts";
|
|
9
10
|
import { coachingDisabledHint, expandDetailHint, reconcileAnsweredAsk } from "../extension/shared.ts";
|
|
10
11
|
import {
|
|
11
12
|
type AsyncRun, AsyncRunTracker, buildPeekDigest,
|
|
12
13
|
dedupeRunsById, getFullRunOutput, IdleCoalescingNotifier,
|
|
13
|
-
isAsyncRunStalled, runDisplayName, runDurationLabel,
|
|
14
|
+
isAsyncRunStalled, observedRunModel, runDisplayName, runDurationLabel,
|
|
14
15
|
} from "../engine/async.ts";
|
|
15
16
|
import type { PollGuard } from "../core/nudge.ts";
|
|
16
17
|
import { fenceUntrusted } from "../core/fence.ts";
|
|
@@ -21,11 +22,14 @@ import type { InProcessBus } from "../bus/inproc.ts";
|
|
|
21
22
|
import type { PersonaController } from "../persona/controller.ts";
|
|
22
23
|
import { emptyUsage } from "../engine/stream.ts";
|
|
23
24
|
import { toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
|
|
25
|
+
import type { TerminalResultArchive } from "../results/archive.ts";
|
|
26
|
+
import { pageLiveResult, projectResultPage, resultReceiptHint } from "../results/retrieval.ts";
|
|
24
27
|
|
|
25
28
|
export interface IntercomToolDeps {
|
|
26
29
|
get lastCtx(): ExtensionContext | undefined;
|
|
27
30
|
set lastCtx(value: ExtensionContext | undefined);
|
|
28
31
|
tracker: AsyncRunTracker;
|
|
32
|
+
archive(ctx: ExtensionContext): TerminalResultArchive;
|
|
29
33
|
completionNotifier: IdleCoalescingNotifier<AsyncRun>;
|
|
30
34
|
intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
|
|
31
35
|
controller: PersonaController;
|
|
@@ -131,6 +135,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
131
135
|
[
|
|
132
136
|
Type.Literal("peek"),
|
|
133
137
|
Type.Literal("result"),
|
|
138
|
+
Type.Literal("results"),
|
|
134
139
|
Type.Literal("steer"),
|
|
135
140
|
Type.Literal("stop"),
|
|
136
141
|
Type.Literal("list"),
|
|
@@ -141,10 +146,15 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
141
146
|
],
|
|
142
147
|
{
|
|
143
148
|
description:
|
|
144
|
-
"peek = status of async sub-agents · result =
|
|
149
|
+
"peek = status of async sub-agents · results = bounded archive summaries (cursor/limit) · result = page a settled live result by to:run-id or a saved report by ref:result-UUID · steer = soft redirect into one by run id (it may ignore it) · stop = request engine cancellation by run id · message = retrieve one drained bus message by message id · list/inbox/reply/send = coaching messages (needs a coaching persona)",
|
|
145
150
|
},
|
|
146
151
|
),
|
|
147
152
|
to: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "result/steer/stop/peek: the async run id (e.g. 'run-1'); stop: \"all\" cancels every running run · send: the child handle (from `list`)" })),
|
|
153
|
+
ref: Type.Optional(Type.String({ maxLength: 43, description: "result: opaque result:<UUID> archive reference; never a run id. Mutually exclusive with to." })),
|
|
154
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "result: stored-body UTF-16 offset (default 0); use nextOffset, never split a surrogate pair." })),
|
|
155
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16000, description: "result: 2..16000 UTF-16 units (default 8000). results: 1..50 summaries (default 20)." })),
|
|
156
|
+
cursor: Type.Optional(Type.Integer({ minimum: 0, description: "results: nextCursor from the prior bounded summary page; omit for the first page." })),
|
|
157
|
+
collect: Type.Optional(Type.Boolean({ description: "result with live to only: true acknowledges the whole report and suppresses its pending completion. Paging alone does not collect." })),
|
|
148
158
|
messageId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "message: the drained bus message id shown by `inbox`" })),
|
|
149
159
|
askId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "reply: the message id of the child's pending question" })),
|
|
150
160
|
message: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_MESSAGE_CHARS, description: "steer/reply/send: the text to deliver" })),
|
|
@@ -154,7 +164,10 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
154
164
|
label: "Intercom",
|
|
155
165
|
description: [
|
|
156
166
|
"Steer, stop and inspect your background sub-agents; a finished run or batch wakes you with a completion follow-up.",
|
|
157
|
-
"Never `peek` healthy children; use it only for status you need. `result { to: runId }`
|
|
167
|
+
"Never `peek` healthy children; use it only for status you need. `result { to: runId }` pages a live settled payload by run id (8000 units default, 16000 maximum).",
|
|
168
|
+
"`results { cursor?, limit? }` lists saved terminal summaries across this session's branches (20 default, 50 maximum); pass nextCursor for the next page.",
|
|
169
|
+
"`result { ref: 'result:<UUID>', offset?, limit? }` reads a saved report, including after reload, without collecting, billing, restarting a worker or waking the model. Memory-only references last only for this activation. Run ids route only the current runtime; never guess a historical run by id.",
|
|
170
|
+
"Use nextOffset for more UTF-16 pages. Only a whole live body returned in one read, or explicit live collect:true, suppresses a pending completion. Archive ref reads reject collect.",
|
|
158
171
|
"`steer` sends a soft course-correction to one (by run id); in-process async runs can receive it mid-run,",
|
|
159
172
|
"while child-engine (MCP/worktree) async runs receive it as a brokered follow-up. After remaining tools return, Pi can consume steering; arbitrary active tool work is not promised to stop immediately.",
|
|
160
173
|
"`message` retrieves a drained bus message by its message id; `list`/`inbox`/`reply`/`send` exchange coaching messages (a child reaches you via `contact_supervisor`)",
|
|
@@ -180,8 +193,29 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
180
193
|
if (refusal) return refusal;
|
|
181
194
|
return { content: [{ type: "text", text: buildPeekDigest(runs, { now: Date.now(), stallMs: d.STALL_FLAG_MS, ...(waiting ? { waitingForSupervisor: waiting } : {}) }) }], details: { action: "peek", ok: true, ...targetSnapshot }, isError: false };
|
|
182
195
|
}
|
|
196
|
+
if (params.action === "results" || (params.action === "result" && params.ref !== undefined)) {
|
|
197
|
+
const invalid = params.to !== undefined || params.collect !== undefined || (params.action === "results" && (params.ref !== undefined || params.offset !== undefined)) || (params.action === "result" && params.cursor !== undefined);
|
|
198
|
+
if (invalid) return { content: [{ type: "text", text: "Archive reads accept ref/offset/limit; results accepts cursor/limit. to and collect are live-only." }], details: failureDetails({ action: params.action }), isError: true };
|
|
199
|
+
const archive = d.archive(ctx);
|
|
200
|
+
if (params.action === "results") {
|
|
201
|
+
const page = archive.list({ ...(params.cursor !== undefined ? { cursor: params.cursor } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) });
|
|
202
|
+
const text = page.ok ? ["Saved terminal reports (not live workers):", ...page.results.map((r) => `${r.ref} · ${sanitizeDisplayLabel(r.label ?? r.agent)} · ${r.status} · ${r.storage}${r.truncated ? " · truncated" : ""}`), ...(page.nextCursor !== undefined ? [`nextCursor: ${page.nextCursor}`] : []), ...(page.warnings.length ? [`Storage warnings: ${page.warnings.join(", ")}`] : [])].join("\n") : page.message;
|
|
203
|
+
return { content: [{ type: "text", text }], details: { action: "results", ...page }, isError: !page.ok };
|
|
204
|
+
}
|
|
205
|
+
const page = archive.read({ ref: params.ref!, ...(params.offset !== undefined ? { offset: params.offset } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) });
|
|
206
|
+
if (!page.ok) {
|
|
207
|
+
return { content: [{ type: "text", text: page.message }], details: { action: "result", ...page }, isError: true };
|
|
208
|
+
}
|
|
209
|
+
// Bounded-page rule: the fenced body is delivered once, in content; details stay
|
|
210
|
+
// bounded metadata exactly like the live result path (no whole page hidden there).
|
|
211
|
+
const { body: _pageBody, ...pageMeta } = page;
|
|
212
|
+
return {
|
|
213
|
+
content: [{ type: "text", text: `${page.summary.ref} · ${page.summary.status} · ${page.summary.storage}${page.summary.truncated ? " · saved body truncated" : ""}\n${projectResultPage(page, page.summary.error)}` }],
|
|
214
|
+
details: { action: "result", ...pageMeta }, isError: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
183
217
|
if (params.action === "result") {
|
|
184
|
-
if (!params.to) {
|
|
218
|
+
if (!params.to || params.cursor !== undefined) {
|
|
185
219
|
return { content: [{ type: "text", text: "intercom result needs { to: <run id> }." }], details: failureDetails({ action: "result", ok: false }), isError: true };
|
|
186
220
|
}
|
|
187
221
|
const run = d.tracker.peek(params.to);
|
|
@@ -199,14 +233,17 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
199
233
|
// same result cannot appear again as a follow-up a moment later. Telling the tracker too
|
|
200
234
|
// makes this retained copy the first thing retention evicts — the supervisor has read it,
|
|
201
235
|
// so it is the cheapest payload in the map to lose.
|
|
202
|
-
d.completionNotifier.discard((pending) => pending.id === run.id);
|
|
203
|
-
d.tracker.markCollected(run.id);
|
|
204
236
|
const full = getFullRunOutput(run);
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
const page = pageLiveResult(full, params.offset, params.limit);
|
|
238
|
+
if (!page) return { content: [{ type: "text", text: "Invalid result offset/limit; use nextOffset and 2..16000 units." }], details: failureDetails({ action: "result", ...targetSnapshot }), isError: true };
|
|
239
|
+
if (params.collect === true || (page.offset === 0 && page.endOfStoredBody)) {
|
|
240
|
+
d.completionNotifier.discard((pending) => pending.id === run.id);
|
|
241
|
+
d.tracker.markCollected(run.id);
|
|
242
|
+
}
|
|
243
|
+
const body = projectResultPage(page, run.error);
|
|
244
|
+
const receipt = resultReceiptHint(run.resultReceipt);
|
|
245
|
+
const displayRun = runDisplayName(run);
|
|
246
|
+
const modelUsed = observedRunModel(run);
|
|
210
247
|
// The wall time this leg took: an explicit collection is one of the paths a completion reaches
|
|
211
248
|
// the supervisor through, so it carries the same reading the passive completion report does.
|
|
212
249
|
const took = runDurationLabel(run);
|
|
@@ -216,8 +253,8 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
216
253
|
const billed = d.childUsage.accountMany([{ key: run.id, usage: run.result?.usage ?? emptyUsage() }]);
|
|
217
254
|
d.publishPersonaCost();
|
|
218
255
|
return {
|
|
219
|
-
content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${
|
|
220
|
-
details: { action: "result", ok: true, status: run.status, ...targetSnapshot },
|
|
256
|
+
content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${modelUsed ? `\nObserved model: ${modelUsed}` : ""}\n${body}${receipt ? `\n${receipt}` : ""}${note ? `\n\n${note}` : ""}` }],
|
|
257
|
+
details: { action: "result", ok: true, status: run.status, ...targetSnapshot, ...(modelUsed ? { modelUsed } : {}), page: { offset: page.offset, nextOffset: page.nextOffset, endOfStoredBody: page.endOfStoredBody }, ...(run.resultReceipt ? { resultReceipt: { ...run.resultReceipt } } : {}) },
|
|
221
258
|
isError: false,
|
|
222
259
|
...toolUsageField(billed),
|
|
223
260
|
};
|
|
@@ -317,7 +354,12 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
317
354
|
return component;
|
|
318
355
|
},
|
|
319
356
|
renderResult(result, { expanded }, theme, context) {
|
|
320
|
-
const details = (result.details ?? {}) as {
|
|
357
|
+
const details = (result.details ?? {}) as {
|
|
358
|
+
action?: string; ok?: boolean; runId?: string; target?: string; message?: string;
|
|
359
|
+
results?: Array<{ truncated?: boolean }>;
|
|
360
|
+
summary?: { ref?: string; storage?: string; storedChars?: number; truncated?: boolean };
|
|
361
|
+
nextCursor?: number; nextOffset?: number; warnings?: readonly string[];
|
|
362
|
+
};
|
|
321
363
|
const state = context?.state as IntercomRenderState | undefined;
|
|
322
364
|
const action = details.action ?? context?.args?.action;
|
|
323
365
|
const savedId = details.runId ?? context?.args?.to;
|
|
@@ -353,7 +395,20 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
353
395
|
const showTitle = !state?.callText || !preview;
|
|
354
396
|
return new Text(`${showTitle ? theme.fg("success", title) : ""}${preview ? `${showTitle ? "\n" : ""}${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}` : ""}`, 0, 0);
|
|
355
397
|
}
|
|
398
|
+
// Archive views are listings/diagnostics, never Markdown reports. A successful archive
|
|
399
|
+
// read carries no runId (live results do) and a summary (its own receipt); the invalid
|
|
400
|
+
// argument mix and failed reads fall through to the literal failure rendering below.
|
|
401
|
+
const listing = details.action === "results" && details.ok === true && Array.isArray(details.results) ? details.results : undefined;
|
|
402
|
+
const archivedPage = details.action === "result" && details.ok === true
|
|
403
|
+
&& typeof details.summary === "object" && details.summary !== null && details.runId === undefined
|
|
404
|
+
? details.summary
|
|
405
|
+
: undefined;
|
|
356
406
|
if (expanded) {
|
|
407
|
+
// Archive pages/listings stay literal via the shared renderer — complete sanitized
|
|
408
|
+
// content, no Markdown re-render of a quote-fenced page or a ref listing.
|
|
409
|
+
if (!failed && (listing || archivedPage)) {
|
|
410
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
411
|
+
}
|
|
357
412
|
// Explicit report/message actions only; missing/running diagnostics and control
|
|
358
413
|
// receipts remain literal. Older history with no action is not guessed from prose.
|
|
359
414
|
if (!failed && ["result", "message", "inbox"].includes(action ?? "")) {
|
|
@@ -361,6 +416,35 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
|
|
|
361
416
|
}
|
|
362
417
|
return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
|
|
363
418
|
}
|
|
419
|
+
if (!failed && listing) {
|
|
420
|
+
// Purpose line first (count + paging/truncation/storage honesty), then a bounded
|
|
421
|
+
// listing preview; the payload's own header line is replaced by the purpose line.
|
|
422
|
+
const truncated = listing.filter((entry) => entry?.truncated === true).length;
|
|
423
|
+
const flags = [
|
|
424
|
+
...(details.nextCursor !== undefined ? [`next page: cursor ${details.nextCursor}`] : []),
|
|
425
|
+
...(truncated > 0 ? [`${truncated} truncated`] : []),
|
|
426
|
+
...(details.warnings?.length ? [`warnings: ${details.warnings.join(", ")}`] : []),
|
|
427
|
+
];
|
|
428
|
+
const purpose = compactInlineText(`results · ${listing.length} retained${flags.length ? ` · ${flags.join(" · ")}` : ""}`, { maxChars: 100 });
|
|
429
|
+
const preview = compactVisibleText(full.split("\n").slice(1).join("\n"), { maxLines: 3, maxLineChars: 100 });
|
|
430
|
+
return new Text(`${theme.fg("success", purpose)}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
|
|
431
|
+
}
|
|
432
|
+
if (!failed && archivedPage) {
|
|
433
|
+
// Page position reads against the STORED body (originals may be truncated); honesty
|
|
434
|
+
// flags surface collapsed so a memory-only or truncated receipt is never mistaken
|
|
435
|
+
// for a durable whole report without expanding.
|
|
436
|
+
const end = details.nextOffset ?? archivedPage.storedChars ?? 0;
|
|
437
|
+
const flags = [
|
|
438
|
+
...(archivedPage.truncated ? ["truncated"] : []),
|
|
439
|
+
...(archivedPage.storage === "memory-only" ? ["memory-only"] : []),
|
|
440
|
+
...(details.warnings?.length ? [`warnings: ${details.warnings.join(", ")}`] : []),
|
|
441
|
+
];
|
|
442
|
+
const purpose = compactInlineText(`result · ${archivedPage.ref ?? "?"} · page ${end}/${archivedPage.storedChars ?? "?"}${flags.length ? ` · ${flags.join(" · ")}` : ""}`, { maxChars: 100 });
|
|
443
|
+
// The payload's receipt header and paging diagnostic line are replaced by the purpose
|
|
444
|
+
// line; the fence wrapper is dropped by the preview, leaving the page body quoted.
|
|
445
|
+
const preview = compactVisibleText(full.split("\n").slice(2).join("\n"), { maxLines: 3, maxLineChars: 100 });
|
|
446
|
+
return new Text(`${theme.fg("success", purpose)}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
|
|
447
|
+
}
|
|
364
448
|
const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
|
|
365
449
|
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
366
450
|
return new Text(`${prefix}${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
package/src/tools/models.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 { Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import { compactInlineText } from "../ui/presentation.ts";
|
|
6
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
6
7
|
|
|
7
8
|
export interface ModelsToolDeps {
|
|
8
9
|
get lastCtx(): ExtensionContext | undefined;
|
|
@@ -45,5 +46,19 @@ export function registerModelsTool(pi: ExtensionAPI, d: ModelsToolDeps): void {
|
|
|
45
46
|
const query = compactInlineText(args.query ?? "(shortlist)", { maxChars: 96 }) || "(shortlist)";
|
|
46
47
|
return new Text(`${theme.fg("toolTitle", theme.bold("models "))}${theme.fg("dim", query)}`, 0, 0);
|
|
47
48
|
},
|
|
49
|
+
renderResult(result, { expanded }, theme, context) {
|
|
50
|
+
// The catalog itself is literal data: the shared renderer bounds it collapsed and
|
|
51
|
+
// shows the complete sanitized listing expanded.
|
|
52
|
+
if (expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
53
|
+
const failed = context?.isError ?? result.isError ?? false;
|
|
54
|
+
if (failed) return renderLiteralToolResult(result, { expanded: false }, theme, context);
|
|
55
|
+
const details = (result.details ?? {}) as { total?: number; shown?: number; shortlist?: boolean };
|
|
56
|
+
const total = typeof details.total === "number" && Number.isFinite(details.total) ? details.total : 0;
|
|
57
|
+
const shown = typeof details.shown === "number" && Number.isFinite(details.shown) ? details.shown : 0;
|
|
58
|
+
const summary = shown === 0
|
|
59
|
+
? "no catalog matches"
|
|
60
|
+
: `${total} catalog models · showing ${shown}${details.shortlist ? " nearby candidates" : " matches"}`;
|
|
61
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("models · "))}${theme.fg("accent", compactInlineText(summary, { maxChars: 96 }))}`, 0, 0);
|
|
62
|
+
},
|
|
48
63
|
});
|
|
49
64
|
}
|
package/src/tools/monitor.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { MonitorManager } from "../core/monitor.ts";
|
|
6
6
|
import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
|
|
7
|
+
import { formatDuration } from "../core/time.ts";
|
|
7
8
|
import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
|
|
8
9
|
import { compactInlineText } from "../ui/presentation.ts";
|
|
10
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
9
11
|
import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
|
|
10
12
|
import type { MonitorArchive, MonitorLogRecord } from "../monitor/archive.ts";
|
|
11
13
|
|
|
@@ -88,12 +90,29 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
|
|
|
88
90
|
...(params.maxEvents !== undefined ? { maxEvents: params.maxEvents } : {}),
|
|
89
91
|
});
|
|
90
92
|
if (!result.ok) return fail(result.error);
|
|
91
|
-
return { content: [{ type: "text" as const, text: `Armed ${result.entry.id} (${compactInlineText(result.entry.label, { maxChars: 120 })}); ${params.mode === "exit" ? "wakes when this job exits" : "wakes on relevant stdout lines"}. Deadline ${new Date(result.entry.expiresAtEpochMs).toISOString()}. ${armWakeAdvice(ctx, "watch", "condition", "Keep this session open. Continue independent work, or end your turn; it wakes you by itself, so never sleep, poll, or arm a `timer` to wait for it.")}` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs } };
|
|
93
|
+
return { content: [{ type: "text" as const, text: `Armed ${result.entry.id} (${compactInlineText(result.entry.label, { maxChars: 120 })}); ${params.mode === "exit" ? "wakes when this job exits" : "wakes on relevant stdout lines"}. Deadline ${new Date(result.entry.expiresAtEpochMs).toISOString()}. ${armWakeAdvice(ctx, "watch", "condition", "Keep this session open. Continue independent work, or end your turn; it wakes you by itself, so never sleep, poll, or arm a `timer` to wait for it.")}` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs, label: compactInlineText(result.entry.label, { maxChars: 120 }), mode: params.mode } };
|
|
92
94
|
},
|
|
93
95
|
renderCall(args, theme) {
|
|
94
96
|
const action = compactInlineText(args.action ?? "", { maxChars: 12 });
|
|
95
97
|
const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
|
|
96
98
|
return new Text(`${theme.fg("toolTitle", theme.bold(`monitor ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
|
|
97
99
|
},
|
|
100
|
+
renderResult(result, { expanded }, theme, context) {
|
|
101
|
+
const details = (result.details ?? {}) as { ok?: boolean; label?: string; mode?: string; expiresAtEpochMs?: number };
|
|
102
|
+
const failed = context?.isError ?? result.isError ?? false;
|
|
103
|
+
const isArm = !failed && typeof details.label === "string" && (details.mode === "output" || details.mode === "exit");
|
|
104
|
+
if (!isArm) {
|
|
105
|
+
// Failures and every list/cancel/result receipt share the one literal renderer:
|
|
106
|
+
// error heading, bounded sanitized preview, expansion hint.
|
|
107
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
108
|
+
}
|
|
109
|
+
if (expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
110
|
+
const left = typeof details.expiresAtEpochMs === "number" && Number.isFinite(details.expiresAtEpochMs)
|
|
111
|
+
? details.expiresAtEpochMs - Date.now()
|
|
112
|
+
: undefined;
|
|
113
|
+
const deadline = left === undefined ? "" : left > 0 ? ` · deadline in ${formatDuration(left)}` : " · deadline reached";
|
|
114
|
+
const summary = compactInlineText(`${details.label} · ${details.mode}${deadline}`, { maxChars: 140 });
|
|
115
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("armed "))}${theme.fg("accent", summary)}`, 0, 0);
|
|
116
|
+
},
|
|
98
117
|
});
|
|
99
118
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
5
|
+
import { RESULT_READ_DEFAULT, RESULT_READ_MAX } from "../results/types.ts";
|
|
6
|
+
import { isReadRequest, isReadReplyFor, readFailure, type ReadReply, type ScopedReader } from "../bus/result-reader.ts";
|
|
7
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
8
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
9
|
+
|
|
10
|
+
const Params = Type.Object({
|
|
11
|
+
ref: Type.String({ minLength: 43, maxLength: 43, description: "Granted opaque result:<UUID> reference, not a file path or run id." }),
|
|
12
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, description: "UTF-16 offset; use nextOffset from the previous page." })),
|
|
13
|
+
limit: Type.Optional(Type.Integer({ minimum: 2, maximum: RESULT_READ_MAX, default: RESULT_READ_DEFAULT, description: "Maximum UTF-16 characters to read." })),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
type PageDetails = Omit<Extract<ReadReply, { ok: true }>, "body">;
|
|
16
|
+
|
|
17
|
+
/** Available only when the parent explicitly grants a scoped reader. No archive browsing,
|
|
18
|
+
* implicit fallback, polling, messages or model wakes. Fencing happens exactly once here.
|
|
19
|
+
* Wrapped in `defineTool` so callers that store the definition in a wider-typed variable
|
|
20
|
+
* (engine registration sites) keep renderer parameter inference. */
|
|
21
|
+
export function makeReadResultTool(reader: Pick<ScopedReader, "read">) {
|
|
22
|
+
return defineTool<typeof Params, PageDetails>({
|
|
23
|
+
name: "read_result",
|
|
24
|
+
label: "Read result",
|
|
25
|
+
description: "Read one bounded page of a terminal result explicitly granted by your supervisor. Use only a supplied result:<UUID> reference. Follow nextOffset for another page; endOfStoredBody ends the retained report, which may be truncated. No search, paths, live runs or polling.",
|
|
26
|
+
parameters: Params,
|
|
27
|
+
async execute(_id, request, signal) {
|
|
28
|
+
if (signal?.aborted) throw new Error(readFailure("cancelled").message);
|
|
29
|
+
if (!isReadRequest(request)) throw new Error(readFailure("invalid").message);
|
|
30
|
+
let result: ReadReply;
|
|
31
|
+
try { result = await reader.read(request, signal); }
|
|
32
|
+
catch { throw new Error(readFailure(signal?.aborted ? "cancelled" : "unavailable").message); }
|
|
33
|
+
if (signal?.aborted) throw new Error(readFailure("cancelled").message);
|
|
34
|
+
try {
|
|
35
|
+
if (!isReadReplyFor(result, request)) throw new Error();
|
|
36
|
+
} catch { throw new Error(readFailure("protocol").message); }
|
|
37
|
+
if (!result.ok) throw new Error(result.message);
|
|
38
|
+
const { body, ...details } = result;
|
|
39
|
+
return { content: [{ type: "text", text: `${JSON.stringify(details)}\n${fenceUntrusted(body)}` }], details };
|
|
40
|
+
},
|
|
41
|
+
renderCall(args, theme) {
|
|
42
|
+
const ref = compactInlineText(args.ref ?? "", { maxChars: 48 });
|
|
43
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("read_result"))}${ref ? ` ${theme.fg("dim", ref)}` : ""}`, 0, 0);
|
|
44
|
+
},
|
|
45
|
+
renderResult(result, { expanded }, theme, context) {
|
|
46
|
+
// The granted page IS literal content: the shared renderer bounds it collapsed with an
|
|
47
|
+
// expansion hint and shows the complete sanitized receipt (details line + fenced body)
|
|
48
|
+
// expanded; native context.isError drives the failure heading.
|
|
49
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/tools/timer.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** Session timer tool — arm a wall-clock wake. */
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
4
|
import { Type } from "typebox";
|
|
4
5
|
import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
|
|
5
6
|
import { formatRemaining, type TimerScheduler } from "../core/timer.ts";
|
|
6
|
-
import { parseInstant } from "../core/time.ts";
|
|
7
|
+
import { formatDuration, parseInstant } from "../core/time.ts";
|
|
8
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
9
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
7
10
|
import { readClockSnapshot } from "../extension/clock.ts";
|
|
8
11
|
|
|
9
12
|
export interface TimerToolDeps {
|
|
@@ -97,7 +100,25 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
|
|
|
97
100
|
}
|
|
98
101
|
const e = r.entry;
|
|
99
102
|
const text = `Armed ${e.id} (${e.label}) — fires in ${formatRemaining(r.remainingMs ?? e.fireAtEpochMs - e.armedAtEpochMs)} [${new Date(e.fireAtEpochMs).toISOString()}]. ${armWakeAdvice(ctx, "alarm", "time-gated step", "You can end this turn now; keep the session open for the alarm.")}`;
|
|
100
|
-
return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, ok: true }, isError: false };
|
|
103
|
+
return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, label: compactInlineText(e.label, { maxChars: 120 }), ok: true }, isError: false };
|
|
104
|
+
},
|
|
105
|
+
renderCall(args, theme) {
|
|
106
|
+
const action = compactInlineText(args.action ?? "", { maxChars: 12 });
|
|
107
|
+
const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
|
|
108
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(`timer ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
|
|
109
|
+
},
|
|
110
|
+
renderResult(result, { expanded }, theme, context) {
|
|
111
|
+
const details = (result.details ?? {}) as { action?: string; ok?: boolean; id?: string; label?: string; fireAtEpochMs?: number };
|
|
112
|
+
const failed = context?.isError ?? result.isError ?? false;
|
|
113
|
+
const fireAt = typeof details.fireAtEpochMs === "number" ? details.fireAtEpochMs : undefined;
|
|
114
|
+
const isArm = !failed && details.action === "arm" && details.ok === true && fireAt !== undefined;
|
|
115
|
+
// Failures and every now/list/cancel receipt share the one literal renderer:
|
|
116
|
+
// error heading, bounded sanitized preview, expansion hint.
|
|
117
|
+
if (!isArm || expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
118
|
+
const left = fireAt - Date.now();
|
|
119
|
+
const due = left > 0 ? `fires in ${formatDuration(left)}` : "due now";
|
|
120
|
+
const summary = compactInlineText(`${details.label ?? details.id ?? "alarm"} · ${due}`, { maxChars: 140 });
|
|
121
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("armed "))}${theme.fg("accent", summary)}`, 0, 0);
|
|
101
122
|
},
|
|
102
123
|
});
|
|
103
124
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Bounded local-work rows. A staged plan is waiting data, never a running agent. */
|
|
2
|
+
import type { CompanionActivity } from "../core/companion.ts";
|
|
3
|
+
import type { AddNodeInput } from "./agent-tree.ts";
|
|
4
|
+
|
|
5
|
+
export const MAX_COMPANION_ACTIVITIES = 8;
|
|
6
|
+
interface ActivityRecord { event: CompanionActivity; startedAt: number }
|
|
7
|
+
|
|
8
|
+
function description(event: CompanionActivity): string {
|
|
9
|
+
const scope = `${event.target} memory`;
|
|
10
|
+
if (event.state === "staged") return `${scope} · proposal prepared; explicit apply required`;
|
|
11
|
+
if (event.state === "failed") return `${scope} · ${event.phase} failed; see the memory result`;
|
|
12
|
+
if (event.state === "done") return `${scope} · ${event.phase === "inspect" ? "inspection complete; ready for review" : event.phase === "apply" ? "staged plan applied" : "plan validation complete"}`;
|
|
13
|
+
return `${scope} · ${event.phase === "inspect" ? "inspecting" : event.phase === "stage" ? "validating consolidation" : "applying staged plan"}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ActivityRows {
|
|
17
|
+
private readonly records = new Map<string, ActivityRecord>();
|
|
18
|
+
accept(event: CompanionActivity, now: number): { node: AddNodeInput; removed: string[] } | undefined {
|
|
19
|
+
const id = `activity:pi-persona-mind:${event.activityId}`;
|
|
20
|
+
const existing = this.records.get(id);
|
|
21
|
+
if (existing && (existing.event.phase !== event.phase || existing.event.target !== event.target || existing.event.state !== "running")) return undefined;
|
|
22
|
+
const removed: string[] = [];
|
|
23
|
+
if (!existing && this.records.size >= MAX_COMPANION_ACTIVITIES) {
|
|
24
|
+
const victim = [...this.records].find(([, record]) => record.event.state !== "running");
|
|
25
|
+
if (!victim) return undefined; // never silently evict active local work
|
|
26
|
+
this.records.delete(victim[0]); removed.push(victim[0]);
|
|
27
|
+
}
|
|
28
|
+
const record = { event, startedAt: existing?.startedAt ?? now };
|
|
29
|
+
this.records.set(id, record);
|
|
30
|
+
const detail = description(event);
|
|
31
|
+
return { removed, node: { id, label: "Mind", kind: "activity", status: event.state === "running" ? "running" : event.state === "failed" ? "failed" : "done", detail, output: `${detail}.\nLocal memory operation; no sub-agent or model execution is implied.\nInspect the memory tool or command result for the full receipt.`, startedAt: record.startedAt, lastAdvanceAt: now } };
|
|
32
|
+
}
|
|
33
|
+
clear(): string[] { const ids = [...this.records.keys()]; this.records.clear(); return ids; }
|
|
34
|
+
}
|
package/src/ui/agent-overlay.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
wrapTextWithAnsi,
|
|
24
24
|
} from "@earendil-works/pi-tui";
|
|
25
25
|
|
|
26
|
-
import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, flattenTree, isWakeSource, nodeGlyph, runningAnnotation } from "./agent-tree.ts";
|
|
26
|
+
import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, effortTag, flattenTree, isWakeSource, isNonAgentNode, nodeGlyph, runningAnnotation, thinkingDetailLine } from "./agent-tree.ts";
|
|
27
27
|
import { LiveClock } from "./live-clock.ts";
|
|
28
28
|
import { visibleWindow } from "./model-picker.ts";
|
|
29
29
|
import { compactInlineText, OPEN_SEQUENCE_TAIL, sanitizeTerminalText } from "./presentation.ts";
|
|
@@ -77,8 +77,12 @@ export function composeAgentRow(input: AgentRowInput): string {
|
|
|
77
77
|
|
|
78
78
|
const prefixCols = 2 + visibleWidth(indent); // "▸ " or " ", then the indent
|
|
79
79
|
const avail = Math.max(0, bodyMax - prefixCols - 2); // 2 = glyph + space
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The compact effort sigla rides the label (` · xh` / ` · xh→h`); its width comes out of the
|
|
81
|
+
// label budget so the appended field never becomes the part a tight row eats first. The final
|
|
82
|
+
// clamp below still guarantees the row fits either way.
|
|
83
|
+
const effort = effortTag(node.thinking, node.thinkingEffective);
|
|
84
|
+
const labelMax = Math.max(8, Math.floor(avail * 0.62) - visibleWidth(effort));
|
|
85
|
+
const label = (safeInline(node.label, labelMax) || "agent") + effort;
|
|
82
86
|
const detailMax = node.detail ? Math.max(0, avail - labelMax - 2) : 0;
|
|
83
87
|
const detail = detailMax > 0 ? safeInline(node.detail ?? "", detailMax) : "";
|
|
84
88
|
const detailText = detail ? t.fg("dim", ` ${detail}`) : "";
|
|
@@ -230,7 +234,7 @@ export class AgentOverlay extends Container {
|
|
|
230
234
|
*/
|
|
231
235
|
private selectedLeaf(leaves: FlatRow[]): FlatRow | undefined {
|
|
232
236
|
const anchored = this.selectedId !== undefined ? leaves.find((r) => r.node.id === this.selectedId) : undefined;
|
|
233
|
-
const leaf = anchored ?? leaves.find((r) => !
|
|
237
|
+
const leaf = anchored ?? leaves.find((r) => !isNonAgentNode(r.node)) ?? leaves[0];
|
|
234
238
|
if (this.selectedId !== undefined && anchored === undefined && leaf !== undefined) this.aimLost = true;
|
|
235
239
|
this.selectedId = leaf?.node.id;
|
|
236
240
|
return leaf;
|
|
@@ -301,7 +305,7 @@ export class AgentOverlay extends Container {
|
|
|
301
305
|
}
|
|
302
306
|
this.addChild(new Spacer(1));
|
|
303
307
|
const stoppable = selected !== undefined && this.canStopNode(selected);
|
|
304
|
-
const steerable = selected !== undefined && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
|
|
308
|
+
const steerable = selected !== undefined && !isNonAgentNode(selected) && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
|
|
305
309
|
this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable, this.stoppableIds(nodes).length)} esc close`), 1, 0));
|
|
306
310
|
this.renderNotice();
|
|
307
311
|
}
|
|
@@ -379,10 +383,15 @@ export class AgentOverlay extends Container {
|
|
|
379
383
|
// A wake source is armed, not a live agent: it has no steer handle, so the agent-only "● live"
|
|
380
384
|
// badge and the steer-unavailable hint below would both be wrong for it.
|
|
381
385
|
const wake = isWakeSource(node);
|
|
386
|
+
const localActivity = node.kind === "activity";
|
|
382
387
|
this.addChild(
|
|
383
|
-
new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", wake ? " ● armed" : " ● live") : ""}`, 1, 0),
|
|
388
|
+
new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", localActivity ? " ● local work" : wake ? " ● armed" : " ● live") : ""}`, 1, 0),
|
|
384
389
|
);
|
|
385
390
|
if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
|
|
391
|
+
// The drill-in has room for FULL effort names (the rows stay compact): an observed level
|
|
392
|
+
// appears only once a terminal result actually reported one.
|
|
393
|
+
const effortLine = thinkingDetailLine(node.thinking, node.thinkingEffective);
|
|
394
|
+
if (effortLine) this.addChild(new Text(t.fg("dim", effortLine), 1, 0));
|
|
386
395
|
this.addChild(new Spacer(1));
|
|
387
396
|
|
|
388
397
|
const raw = node.output?.trim()
|
|
@@ -401,7 +410,7 @@ export class AgentOverlay extends Container {
|
|
|
401
410
|
for (const line of all.slice(start, end)) this.addChild(new Text(t.fg("toolOutput", line), 1, 0));
|
|
402
411
|
if (this.detailScroll > 0) this.addChild(new Text(t.fg("dim", `▼ ${this.detailScroll} newer`), 1, 0));
|
|
403
412
|
|
|
404
|
-
const steerable = live && (this.actions.canSteer?.(node.id) ?? false);
|
|
413
|
+
const steerable = live && !isNonAgentNode(node) && (this.actions.canSteer?.(node.id) ?? false);
|
|
405
414
|
const stoppable = this.canStopNode(node);
|
|
406
415
|
if (this.steering && !steerable) this.steering = false; // agent finished mid-compose
|
|
407
416
|
this.addChild(new Spacer(1));
|
|
@@ -413,7 +422,7 @@ export class AgentOverlay extends Container {
|
|
|
413
422
|
} else {
|
|
414
423
|
this.addChild(new Text(t.fg("dim", `esc back · ↑↓ scroll${this.hintLine("detail", stoppable, steerable)}`), 1, 0));
|
|
415
424
|
this.renderNotice();
|
|
416
|
-
if (live && !steerable && !
|
|
425
|
+
if (live && !steerable && !isNonAgentNode(node)) {
|
|
417
426
|
this.addChild(new Text(t.fg("dim", "(steer unavailable: no live handle yet, or this engine/broker does not expose one)"), 1, 0));
|
|
418
427
|
}
|
|
419
428
|
}
|
|
@@ -448,7 +457,8 @@ export class AgentOverlay extends Container {
|
|
|
448
457
|
} else if (hadNotice) this.refresh();
|
|
449
458
|
return;
|
|
450
459
|
}
|
|
451
|
-
|
|
460
|
+
const detailNode = this.tree.snapshot().find((node) => node.id === this.detailId);
|
|
461
|
+
if (keyData === "s" && detailNode && !isNonAgentNode(detailNode) && (this.actions.canSteer?.(this.detailId) ?? false)) {
|
|
452
462
|
this.steering = true;
|
|
453
463
|
this.steerBuffer = "";
|
|
454
464
|
this.refresh();
|
|
@@ -484,7 +494,7 @@ export class AgentOverlay extends Container {
|
|
|
484
494
|
// Steer straight from the list: drill into the selected agent with the
|
|
485
495
|
// compose line already open (same gate as the detail view's `s`).
|
|
486
496
|
const leaf = this.aimedLeaf(leaves);
|
|
487
|
-
if (leaf && leaf.node.status === "running" && (this.actions.canSteer?.(leaf.node.id) ?? false)) {
|
|
497
|
+
if (leaf && !isNonAgentNode(leaf.node) && leaf.node.status === "running" && (this.actions.canSteer?.(leaf.node.id) ?? false)) {
|
|
488
498
|
this.detailId = leaf.node.id;
|
|
489
499
|
this.detailScroll = 0;
|
|
490
500
|
this.steering = true;
|
|
@@ -504,8 +514,8 @@ export class AgentOverlay extends Container {
|
|
|
504
514
|
/** Whether an agent can be stopped right now: it must be running, and the caller's `canStop`
|
|
505
515
|
* gate (defaulting to "yes" when omitted) must agree. Shared by both hints and `tryStop`, so
|
|
506
516
|
* the hint that advertises "x stop" and the handler that actually gates it never diverge. */
|
|
507
|
-
private canStopNode(node: Pick<AgentNode, "id" | "status">): boolean {
|
|
508
|
-
return node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
|
|
517
|
+
private canStopNode(node: Pick<AgentNode, "id" | "status" | "kind">): boolean {
|
|
518
|
+
return node.kind !== "activity" && node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
|
|
509
519
|
}
|
|
510
520
|
|
|
511
521
|
/** The agents `X` would stop: every leaf that `x` could stop, wake sources excluded. `X` means
|
|
@@ -513,7 +523,7 @@ export class AgentOverlay extends Container {
|
|
|
513
523
|
* only by an explicit `x` on its own row. One list feeds the hint count, the confirmation prompt
|
|
514
524
|
* and the bulk stop, so the three always agree. */
|
|
515
525
|
private stoppableIds(snap: AgentNode[] = this.tree.snapshot()): string[] {
|
|
516
|
-
return this.leafRows(snap).filter((r) => !
|
|
526
|
+
return this.leafRows(snap).filter((r) => !isNonAgentNode(r.node) && this.canStopNode(r.node)).map((r) => r.node.id);
|
|
517
527
|
}
|
|
518
528
|
|
|
519
529
|
/** The trailing "x stop"/"X stop all"/"s steer" hint fragment for an action line — the list's
|