@aeondave/pi-persona 1.17.1 → 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 +103 -64
- package/CHANGELOG.md +310 -1
- package/README.md +37 -23
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +280 -92
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +52 -17
- package/docs/MONITORS.md +62 -3
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +139 -38
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +15 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +26 -0
- package/src/bridge.ts +28 -10
- 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 +436 -353
- package/src/core/capabilities.ts +1 -1
- package/src/core/config.ts +15 -10
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +1 -1
- package/src/core/timer.ts +34 -9
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +95 -27
- package/src/engine/async.ts +213 -155
- package/src/engine/child.ts +12 -4
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +99 -53
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +10 -20
- 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 +47 -18
- 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 +54 -28
- package/src/extension/hooks.ts +952 -868
- package/src/extension/identity.ts +41 -75
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +32 -7
- package/src/extension.ts +2965 -2611
- package/src/loader.ts +11 -3
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +33 -6
- package/src/orchestration/flow-run.ts +160 -32
- package/src/orchestration/flow.ts +20 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -11
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- 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 +8 -4
- 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/background-run.ts +151 -0
- package/src/tools/council.ts +108 -66
- package/src/tools/delegate-tool.ts +349 -283
- package/src/tools/delegate.ts +35 -216
- 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-work.ts +18 -53
- package/src/tools/exocom.ts +35 -22
- package/src/tools/flow.ts +103 -30
- package/src/tools/intercom-tool.ts +202 -128
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +34 -9
- package/src/tools/monitor.ts +58 -6
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +34 -15
- package/src/ui/agent-overlay.ts +65 -16
- package/src/ui/agent-tree.ts +127 -19
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/persona-settings.ts +9 -3
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +366 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
package/src/tools/monitor.ts
CHANGED
|
@@ -3,34 +3,69 @@ 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 type { MonitorManager } from "../core/monitor.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
|
|
7
|
+
import { formatDuration } from "../core/time.ts";
|
|
8
|
+
import { armWakeAdvice, failureDetails } from "../extension/shared.ts";
|
|
7
9
|
import { compactInlineText } from "../ui/presentation.ts";
|
|
10
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
11
|
+
import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
|
|
12
|
+
import type { MonitorArchive, MonitorLogRecord } from "../monitor/archive.ts";
|
|
13
|
+
|
|
14
|
+
/** A bounded, fenced read of a session-owned log; never adds a wake by itself. */
|
|
15
|
+
export function renderMonitorLog(record: MonitorLogRecord, full = false): string {
|
|
16
|
+
const limit = full ? 16_384 : 4_000;
|
|
17
|
+
const tail = (text: string) => {
|
|
18
|
+
// Match the live monitor projection's inert bidi/separator policy without rewriting captures.
|
|
19
|
+
const safe = neutralizeMonitorUnicode(stripTerminalControls(text));
|
|
20
|
+
return safe.length > limit ? `…[display truncated; use result with full=true for the retained capture]\n${safe.slice(-limit)}` : safe || "(no output)";
|
|
21
|
+
};
|
|
22
|
+
return [
|
|
23
|
+
`${compactInlineText(record.entry.id)} (${compactInlineText(record.entry.label)}) — ${record.outcome}`,
|
|
24
|
+
...(record.truncated ? ["Log capture is bounded; older output was truncated."] : []),
|
|
25
|
+
...(record.storageWarning ? [compactInlineText(record.storageWarning, { maxChars: 400 })] : []),
|
|
26
|
+
fenceUntrusted(`${tail(record.text)}\nstdout:\n${tail(record.stdout)}\nstderr:\n${tail(record.stderr)}`),
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
export interface MonitorToolDeps {
|
|
10
31
|
get lastCtx(): ExtensionContext | undefined;
|
|
11
32
|
set lastCtx(value: ExtensionContext | undefined);
|
|
12
33
|
canRun(): boolean;
|
|
13
34
|
monitors: Pick<MonitorManager, "arm" | "list" | "cancel">;
|
|
35
|
+
archive?: Pick<MonitorArchive, "result" | "list">;
|
|
14
36
|
}
|
|
15
37
|
|
|
16
38
|
export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void {
|
|
17
39
|
pi.registerTool({
|
|
18
40
|
name: "monitor",
|
|
19
41
|
label: "Monitor",
|
|
20
|
-
description: "Watch a job, file, log or system event using a background program. arm starts command (an executable) with args in the current workspace; no implicit shell. The argument list allows up to 64 literal arguments, each up to 8192 characters and 24,000 characters in total. mode=output wakes on nonempty stdout lines; write an adapter that prints only relevant changes. mode=exit wakes once when the launched job finishes. Returns immediately; continue independent work, then end your turn
|
|
42
|
+
description: "Watch a job, file, log or system event using a background program. arm starts command (an executable) with args in the current workspace; no implicit shell. The argument list allows up to 64 literal arguments, each up to 8192 characters and 24,000 characters in total. mode=output wakes on nonempty stdout lines; write an adapter that prints only relevant changes. mode=exit wakes once when the launched job finishes. Returns immediately; continue independent work, then end your turn: events wake you automatically. Requires monitor AND bash permission. Default: one event, ten-minute deadline; max eight monitors, twenty events each, twenty-four hours. list shows active watches; result without id lists retained session logs, result with id reads a bounded log and outcome (also after reload/resume), full=true reads the complete retained capture; do not poll result to wait. Logs span the whole session tree, including sibling conversation branches; branch navigation does not hide captures. They are bounded session artifacts, not complete unlimited transcripts. cancel stops the owned process tree and queued wakes. Keep this Pi session open; watches stop on session shutdown/reload, and a headless `pi -p` run exits after its turn. A wake source, not a wait: for a fixed time use `timer`; sub-agents and Exocom wake you on their own.",
|
|
21
43
|
parameters: Type.Object({
|
|
22
|
-
action: Type.Union([Type.Literal("arm"), Type.Literal("list"), Type.Literal("cancel")]),
|
|
44
|
+
action: Type.Union([Type.Literal("arm"), Type.Literal("list"), Type.Literal("cancel"), Type.Literal("result")]),
|
|
23
45
|
command: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "arm: executable, e.g. node, python or powershell.exe. Use args for script paths and arguments." })),
|
|
24
46
|
args: Type.Optional(Type.Array(Type.String({ maxLength: 8192 }), { maxItems: 64, description: "arm: literal argv (up to 64 arguments, each up to 8192 characters; combined argument strings maximum 24000 characters). For shell syntax, explicitly launch the appropriate shell and pass a script file." })),
|
|
25
47
|
label: Type.Optional(Type.String({ minLength: 1, maxLength: 120, description: "arm: short purpose, e.g. build finished or new application error." })),
|
|
26
48
|
mode: Type.Optional(Type.Union([Type.Literal("output"), Type.Literal("exit")], { description: "arm: output for event-producing watchers; exit for job completion. Required." })),
|
|
27
49
|
timeoutSeconds: Type.Optional(Type.Number({ minimum: 1, maximum: 86400, description: "arm: lifetime deadline, default 600 seconds. Expiry stops the process and reports a timeout." })),
|
|
28
50
|
maxEvents: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "arm/output: stop after this many distinct event lines, default 1." })),
|
|
29
|
-
id: Type.Optional(Type.String({ minLength: 1, description: "cancel: monitor id returned by arm." })),
|
|
51
|
+
id: Type.Optional(Type.String({ minLength: 1, description: "cancel/result: monitor id returned by arm; omit for result to list retained logs." })),
|
|
52
|
+
full: Type.Optional(Type.Boolean({ description: "result: show the complete bounded capture instead of a short tail. Never recovers output already truncated by retention." })),
|
|
30
53
|
}),
|
|
31
54
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
32
55
|
d.lastCtx = ctx;
|
|
33
56
|
const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: failureDetails({ ok: false }), isError: true });
|
|
57
|
+
if (params.action === "result") {
|
|
58
|
+
if (!d.archive) return fail("Monitor logs are unavailable in this session.");
|
|
59
|
+
if (params.id === undefined) {
|
|
60
|
+
const records = d.archive.list();
|
|
61
|
+
return { content: [{ type: "text" as const, text: records.length
|
|
62
|
+
? records.map((r) => `${compactInlineText(r.entry.id)} (${compactInlineText(r.entry.label)}) — ${r.outcome}${r.storageWarning ? "; storage limited" : ""}`).join("\n")
|
|
63
|
+
: "No retained monitor logs in this session." }], details: { ok: true, count: records.length } };
|
|
64
|
+
}
|
|
65
|
+
const record = d.archive.result(params.id);
|
|
66
|
+
if (!record) return fail(`Monitor log unavailable for ${compactInlineText(params.id)}; it may predate logging or have exceeded session retention. Use result without id to list available logs.`);
|
|
67
|
+
return { content: [{ type: "text" as const, text: renderMonitorLog(record, params.full === true) }], details: { ok: true, id: record.entry.id, outcome: record.outcome, truncated: record.truncated } };
|
|
68
|
+
}
|
|
34
69
|
if (params.action === "list") {
|
|
35
70
|
const entries = d.monitors.list();
|
|
36
71
|
return { content: [{ type: "text" as const, text: entries.length ? entries.map((e) => `${e.id} (${compactInlineText(e.label, { maxChars: 120 })}) — ${e.mode}; deadline ${new Date(e.expiresAtEpochMs).toISOString()}`).join("\n") : "No active monitors." }], details: { ok: true, count: entries.length } };
|
|
@@ -40,7 +75,7 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
|
|
|
40
75
|
const cancelled = d.monitors.cancel(params.id);
|
|
41
76
|
return { content: [{ type: "text" as const, text: cancelled ? `Cancelled ${params.id}; its queued events were removed.` : `No active monitor or queued event for ${params.id}.` }], details: { ok: true, cancelled } };
|
|
42
77
|
}
|
|
43
|
-
if (params.action !== "arm") return fail("monitor action must be arm, list or cancel.");
|
|
78
|
+
if (params.action !== "arm") return fail("monitor action must be arm, list, result or cancel.");
|
|
44
79
|
if (!d.canRun()) return fail("monitor arm requires both monitor and bash permission for the active persona.");
|
|
45
80
|
if (signal?.aborted) return fail("monitor arm cancelled before launch.");
|
|
46
81
|
if (typeof params.command !== "string" || !params.command.trim() || typeof params.label !== "string" || !params.label.trim()) return fail("monitor arm needs { command, label, mode }.");
|
|
@@ -55,12 +90,29 @@ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void
|
|
|
55
90
|
...(params.maxEvents !== undefined ? { maxEvents: params.maxEvents } : {}),
|
|
56
91
|
});
|
|
57
92
|
if (!result.ok) return fail(result.error);
|
|
58
|
-
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()}. Keep this session open. Continue independent work, or end your turn
|
|
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 } };
|
|
59
94
|
},
|
|
60
95
|
renderCall(args, theme) {
|
|
61
96
|
const action = compactInlineText(args.action ?? "", { maxChars: 12 });
|
|
62
97
|
const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
|
|
63
98
|
return new Text(`${theme.fg("toolTitle", theme.bold(`monitor ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
|
|
64
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
|
+
},
|
|
65
117
|
});
|
|
66
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
|
-
import { failureDetails } from "../extension/shared.ts";
|
|
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 {
|
|
@@ -13,11 +16,8 @@ export interface TimerToolDeps {
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
|
|
16
|
-
// ── timer tool
|
|
17
|
-
//
|
|
18
|
-
// arm an alarm, end your turn, and when it expires the extension injects a follow-up that
|
|
19
|
-
// resumes you (idle-gated so it starts a fresh turn instead of stranding). Up to 32 alarms may be
|
|
20
|
-
// armed; cancel/list them. In-memory per session (a reload/new session clears armed alarms).
|
|
19
|
+
// ── timer tool: a wall-clock alarm that WAKES the idle session when it fires (see core/timer.ts) ───
|
|
20
|
+
// Up to 32 alarms; in-memory per session (a reload/new session clears them).
|
|
21
21
|
const TimerToolParams = Type.Object({
|
|
22
22
|
action: Type.Union([Type.Literal("arm"), Type.Literal("cancel"), Type.Literal("list"), Type.Literal("now")], {
|
|
23
23
|
description: "arm = schedule a wakeup · cancel = drop one by id · list = show armed alarms · now = read the fresh clock",
|
|
@@ -27,18 +27,19 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
|
|
|
27
27
|
),
|
|
28
28
|
delaySeconds: Type.Optional(Type.Number({ description: "arm: fire this many seconds from now; it is rounded to milliseconds and must produce a delay from 1 second through 86400 seconds. Give this OR atIso, not both." })),
|
|
29
29
|
atIso: Type.Optional(Type.String({ description: "arm: fire at this absolute ISO-8601 instant (date-time must include Z or a numeric timezone offset, e.g. '2026-07-11T19:00:00Z'). Give this OR delaySeconds, not both." })),
|
|
30
|
-
label: Type.Optional(Type.String({ description: "arm: a short human label for the alarm (e.g. 'status review')." })),
|
|
30
|
+
label: Type.Optional(Type.String({ description: "arm: a short human label for the alarm (e.g. 'status review'). Labels longer than 120 characters are clipped." })),
|
|
31
31
|
id: Type.Optional(Type.String({ description: "cancel: the timer id to cancel (e.g. 'timer-1')." })),
|
|
32
32
|
});
|
|
33
33
|
pi.registerTool({
|
|
34
34
|
name: "timer",
|
|
35
35
|
label: "Timer",
|
|
36
36
|
description: [
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
37
|
+
"Wake yourself at a wall-clock moment the world decides (an external deadline, a service window,",
|
|
38
|
+
"a reminder at a fixed time). `arm` with { message, delaySeconds } or { message, atIso }; continue",
|
|
39
|
+
"independent work, then end your turn when nothing else remains; when it fires, a follow-up",
|
|
40
|
+
"carrying your message arrives once you are idle. `cancel` by id, `list` the armed ones.",
|
|
41
|
+
"Never use it to wait for a sub-agent or monitor (they wake you on their own) or to poll: if something can report the event, use `monitor`.",
|
|
42
|
+
"Up to 32 alarms may be armed at once. The session must remain open;",
|
|
42
43
|
"alarms are in-memory and cleared on shutdown or reload. `now` returns a fresh UTC time plus",
|
|
43
44
|
"the local timezone and numeric offset.",
|
|
44
45
|
].join(" "),
|
|
@@ -98,8 +99,26 @@ export function registerTimerTool(pi: ExtensionAPI, d: TimerToolDeps): void {
|
|
|
98
99
|
return { content: [{ type: "text", text: r.error ?? "timer arm failed." }], details: failureDetails({ action: "arm", ok: false }), isError: true };
|
|
99
100
|
}
|
|
100
101
|
const e = r.entry;
|
|
101
|
-
const text = `Armed ${e.id} (${e.label}) — fires in ${formatRemaining(r.remainingMs ?? e.fireAtEpochMs - e.armedAtEpochMs)} [${new Date(e.fireAtEpochMs).toISOString()}].
|
|
102
|
-
return { content: [{ type: "text", text }], details: { action: "arm", id: e.id, fireAtEpochMs: e.fireAtEpochMs, ok: true }, isError: false };
|
|
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.")}`;
|
|
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);
|
|
103
122
|
},
|
|
104
123
|
});
|
|
105
124
|
}
|
package/src/ui/agent-overlay.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* A focusable overlay over the unified agent tree, opened with ctx.ui.custom
|
|
3
3
|
* (overlay: true). ↑↓ navigate the *agents* (leaf rows; a parent like "delegate"
|
|
4
4
|
* is a non-selectable header), ⏎ drills into one — its live output in an
|
|
5
|
-
* auto-scrolling viewport sized to the terminal — `x` stops it, `
|
|
6
|
-
* (from the list too), esc backs out / closes. Subscribes to the tree, so it
|
|
5
|
+
* auto-scrolling viewport sized to the terminal — `x` stops it, `X` (twice) stops every
|
|
6
|
+
* running agent, `s` steers it (from the list too), esc backs out / closes. Subscribes to the tree, so it
|
|
7
7
|
* re-renders live as agents stream.
|
|
8
8
|
*
|
|
9
9
|
* The pure parts (flatten/order, glyphs, scroll window) live in agent-tree.ts /
|
|
@@ -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,
|
|
26
|
+
import { type AgentNode, type AgentTree, type FlatRow, deriveWaitingNodeIds, effortTag, flattenTree, isWakeSource, 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,13 +77,17 @@ 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}`) : "";
|
|
85
89
|
|
|
86
|
-
const labelLine = `${indent}${
|
|
90
|
+
const labelLine = `${indent}${nodeGlyph(node)} ${label}`;
|
|
87
91
|
const prefixed = selected ? t.fg("accent", `▸ ${labelLine}`) : ` ${labelLine}`;
|
|
88
92
|
let body = `${prefixed}${detailText}`;
|
|
89
93
|
if (visibleWidth(body) > bodyMax) body = truncateToWidth(body, bodyMax, "");
|
|
@@ -119,6 +123,7 @@ export class AgentOverlay extends Container {
|
|
|
119
123
|
private unsubscribe: () => void;
|
|
120
124
|
private selectedId: string | undefined; // the selected agent's *id*, never its row index
|
|
121
125
|
private aimLost = false; // the aimed-at agent settled: swallow the next directed keystroke
|
|
126
|
+
private stopAllArmed: string | undefined; // the stoppable set the "press X again" prompt armed on
|
|
122
127
|
private listScroll = 0; // list rows scrolled off the top (keeps the selection visible)
|
|
123
128
|
private detailId: string | undefined;
|
|
124
129
|
private detailScroll = 0; // output lines scrolled up from the bottom (0 = latest)
|
|
@@ -209,9 +214,11 @@ export class AgentOverlay extends Container {
|
|
|
209
214
|
return deriveWaitingNodeIds(nodes, (id) => this.actions.isWaitingForSupervisor?.(id) === true);
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
/** The selectable rows: every leaf, plus a container that carries a stop of its own (a council/flow run
|
|
218
|
+
* root), so `x` reaches the whole run instead of one leg. Other containers stay headers. */
|
|
212
219
|
private leafRows(snap: AgentNode[] = this.tree.snapshot()): FlatRow[] {
|
|
213
220
|
const parents = new Set(snap.map((n) => n.parentId).filter((p): p is string => p !== undefined));
|
|
214
|
-
return flattenTree(snap).filter((r) => !parents.has(r.node.id));
|
|
221
|
+
return flattenTree(snap).filter((r) => !parents.has(r.node.id) || this.actions.canStop?.(r.node.id) === true);
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
/**
|
|
@@ -220,11 +227,14 @@ export class AgentOverlay extends Container {
|
|
|
220
227
|
* (stop) and `s` (steer) onto a *different* agent whenever an earlier sibling went
|
|
221
228
|
* away. When the aimed-at agent is the one that vanishes, fall back to the head of
|
|
222
229
|
* the list, where the ▸ marker visibly shows the user their target changed — and
|
|
223
|
-
* record that the aim was lost, for the directed keys (see `aimedLeaf`).
|
|
230
|
+
* record that the aim was lost, for the directed keys (see `aimedLeaf`). The (re-)anchor
|
|
231
|
+
* prefers an agent row: a monitor or timer that merely happens to be listed first must not
|
|
232
|
+
* inherit the marker, or the next `x` could cancel a two-hour watch. A wake row takes it only
|
|
233
|
+
* when no agent is left.
|
|
224
234
|
*/
|
|
225
235
|
private selectedLeaf(leaves: FlatRow[]): FlatRow | undefined {
|
|
226
236
|
const anchored = this.selectedId !== undefined ? leaves.find((r) => r.node.id === this.selectedId) : undefined;
|
|
227
|
-
const leaf = anchored ?? leaves[0];
|
|
237
|
+
const leaf = anchored ?? leaves.find((r) => !isWakeSource(r.node)) ?? leaves[0];
|
|
228
238
|
if (this.selectedId !== undefined && anchored === undefined && leaf !== undefined) this.aimLost = true;
|
|
229
239
|
this.selectedId = leaf?.node.id;
|
|
230
240
|
return leaf;
|
|
@@ -296,7 +306,7 @@ export class AgentOverlay extends Container {
|
|
|
296
306
|
this.addChild(new Spacer(1));
|
|
297
307
|
const stoppable = selected !== undefined && this.canStopNode(selected);
|
|
298
308
|
const steerable = selected !== undefined && selected.status === "running" && (this.actions.canSteer?.(selected.id) ?? false);
|
|
299
|
-
this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable)} esc close`), 1, 0));
|
|
309
|
+
this.addChild(new Text(t.fg("dim", `↑↓ navigate ⏎ open${this.hintLine("list", stoppable, steerable, this.stoppableIds(nodes).length)} esc close`), 1, 0));
|
|
300
310
|
this.renderNotice();
|
|
301
311
|
}
|
|
302
312
|
|
|
@@ -370,10 +380,17 @@ export class AgentOverlay extends Container {
|
|
|
370
380
|
return;
|
|
371
381
|
}
|
|
372
382
|
const live = node.status === "running";
|
|
383
|
+
// A wake source is armed, not a live agent: it has no steer handle, so the agent-only "● live"
|
|
384
|
+
// badge and the steer-unavailable hint below would both be wrong for it.
|
|
385
|
+
const wake = isWakeSource(node);
|
|
373
386
|
this.addChild(
|
|
374
|
-
new Text(`${
|
|
387
|
+
new Text(`${nodeGlyph(node)} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", wake ? " ● armed" : " ● live") : ""}`, 1, 0),
|
|
375
388
|
);
|
|
376
389
|
if (node.detail) this.addChild(new Text(t.fg("dim", safeInline(node.detail)), 1, 0));
|
|
390
|
+
// The drill-in has room for FULL effort names (the rows stay compact): an observed level
|
|
391
|
+
// appears only once a terminal result actually reported one.
|
|
392
|
+
const effortLine = thinkingDetailLine(node.thinking, node.thinkingEffective);
|
|
393
|
+
if (effortLine) this.addChild(new Text(t.fg("dim", effortLine), 1, 0));
|
|
377
394
|
this.addChild(new Spacer(1));
|
|
378
395
|
|
|
379
396
|
const raw = node.output?.trim()
|
|
@@ -404,7 +421,7 @@ export class AgentOverlay extends Container {
|
|
|
404
421
|
} else {
|
|
405
422
|
this.addChild(new Text(t.fg("dim", `esc back · ↑↓ scroll${this.hintLine("detail", stoppable, steerable)}`), 1, 0));
|
|
406
423
|
this.renderNotice();
|
|
407
|
-
if (live && !steerable) {
|
|
424
|
+
if (live && !steerable && !wake) {
|
|
408
425
|
this.addChild(new Text(t.fg("dim", "(steer unavailable: no live handle yet, or this engine/broker does not expose one)"), 1, 0));
|
|
409
426
|
}
|
|
410
427
|
}
|
|
@@ -413,6 +430,8 @@ export class AgentOverlay extends Container {
|
|
|
413
430
|
handleInput(keyData: string): void {
|
|
414
431
|
const hadNotice = this.notice !== undefined;
|
|
415
432
|
this.notice = undefined; // any key clears a previous refusal notice
|
|
433
|
+
const armed = this.stopAllArmed;
|
|
434
|
+
this.stopAllArmed = undefined; // only an immediately repeated X confirms
|
|
416
435
|
const kb = getKeybindings();
|
|
417
436
|
if (this.detailId) {
|
|
418
437
|
// Steer mode: type a message into the running agent. Capture all printable keys
|
|
@@ -483,6 +502,8 @@ export class AgentOverlay extends Container {
|
|
|
483
502
|
} else if (keyData === "x") {
|
|
484
503
|
const leaf = this.aimedLeaf(leaves);
|
|
485
504
|
if (leaf) this.tryStop(leaf.node.id);
|
|
505
|
+
} else if (keyData === "X") {
|
|
506
|
+
this.stopAll(armed);
|
|
486
507
|
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
487
508
|
this.close();
|
|
488
509
|
} else if (hadNotice) this.refresh(); // unhandled key: still repaint so a cleared notice disappears
|
|
@@ -495,11 +516,20 @@ export class AgentOverlay extends Container {
|
|
|
495
516
|
return node.status === "running" && (this.actions.canStop?.(node.id) ?? true);
|
|
496
517
|
}
|
|
497
518
|
|
|
498
|
-
/** The
|
|
499
|
-
*
|
|
500
|
-
|
|
519
|
+
/** The agents `X` would stop: every leaf that `x` could stop, wake sources excluded. `X` means
|
|
520
|
+
* "stop every agent"; a monitor's job (it may be a two-hour batch) or a timer's alarm is cancelled
|
|
521
|
+
* only by an explicit `x` on its own row. One list feeds the hint count, the confirmation prompt
|
|
522
|
+
* and the bulk stop, so the three always agree. */
|
|
523
|
+
private stoppableIds(snap: AgentNode[] = this.tree.snapshot()): string[] {
|
|
524
|
+
return this.leafRows(snap).filter((r) => !isWakeSource(r.node) && this.canStopNode(r.node)).map((r) => r.node.id);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The trailing "x stop"/"X stop all"/"s steer" hint fragment for an action line — the list's
|
|
528
|
+
* three-space style or the detail view's " · " style, the two views' only difference here.
|
|
529
|
+
* `stopAll` (list only) is the stoppable count; one agent is what plain `x` is for. */
|
|
530
|
+
private hintLine(kind: "list" | "detail", stoppable: boolean, steerable: boolean, stopAll = 0): string {
|
|
501
531
|
const sep = kind === "list" ? " " : " · ";
|
|
502
|
-
return `${stoppable ? `${sep}x stop` : ""}${steerable ? `${sep}s steer` : ""}`;
|
|
532
|
+
return `${stoppable ? `${sep}x stop` : ""}${stopAll >= 2 ? `${sep}X stop all (${stopAll})` : ""}${steerable ? `${sep}s steer` : ""}`;
|
|
503
533
|
}
|
|
504
534
|
|
|
505
535
|
/** The one-line refusal notice, shown identically under the list and the detail view. */
|
|
@@ -518,6 +548,25 @@ export class AgentOverlay extends Container {
|
|
|
518
548
|
this.refresh();
|
|
519
549
|
}
|
|
520
550
|
|
|
551
|
+
/** `X`: stop every stoppable agent after one confirming press. The prompt states how many agents
|
|
552
|
+
* it covers and arms on the exact set it saw, so a set that changed since (or any key in
|
|
553
|
+
* between) asks again — a bulk, irreversible stop is never fired on a stale confirmation. */
|
|
554
|
+
private stopAll(armed: string | undefined): void {
|
|
555
|
+
const ids = this.stoppableIds();
|
|
556
|
+
const key = ids.join("|");
|
|
557
|
+
if (ids.length >= 2) {
|
|
558
|
+
if (armed === key) {
|
|
559
|
+
let stopped = 0;
|
|
560
|
+
for (const id of ids) if (this.actions.onStop?.(id)) stopped++;
|
|
561
|
+
this.notice = `stop requested for ${stopped} agent${stopped === 1 ? "" : "s"}; queued or later stages may still start`;
|
|
562
|
+
} else {
|
|
563
|
+
this.stopAllArmed = key;
|
|
564
|
+
this.notice = `press X again to stop ${ids.length} running agents`;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
this.refresh();
|
|
568
|
+
}
|
|
569
|
+
|
|
521
570
|
private close(): void {
|
|
522
571
|
this.dispose();
|
|
523
572
|
this.done();
|