@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.
Files changed (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -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 { failureDetails } from "../extension/shared.ts";
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 to receive events 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; cancel stops the owned process tree and queued wakes. Keep this Pi session open; watches stop on session shutdown/reload. Do not wrap delegate or Exocom in a monitor: their notifications are already automatic.",
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 and wait for the automatic notice.` }], 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 } };
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
+ }
@@ -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 (arm a wall-clock alarm that WAKES the session when it fires) ───
17
- // Solves the "wait N minutes for a scheduled event or service window" problem WITHOUT a poll loop:
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
- "Arm a wall-clock ALARM that wakes you when it fires — the token-cheap way to wait for a",
38
- "fixed moment (a scheduled event, a service window, a scheduled re-check) instead of a",
39
- "poll loop. `arm` with { message, delaySeconds } or { message, atIso }; end your turn; when",
40
- "it expires a follow-up carrying your message is injected and you resume. `cancel` by id,",
41
- "`list` the armed ones. Up to 32 alarms may be armed at once. The session must remain open;",
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()}]. On fire I'll be woken with: "${e.message}". You can end this turn now; keep the session open for the alarm.`;
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
  }
@@ -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, `s` steers 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, GLYPH, runningAnnotation } from "./agent-tree.ts";
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
- const labelMax = Math.max(8, Math.floor(avail * 0.62));
81
- const label = safeInline(node.label, labelMax) || "agent";
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}${GLYPH[node.status]} ${label}`;
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(`${GLYPH[node.status]} ${t.fg("accent", t.bold(safeInline(node.label) || "agent"))}${live ? t.fg("success", " ● live") : ""}`, 1, 0),
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 trailing "x stop"/"s steer" hint fragment for an action line — the list's three-space
499
- * style or the detail view's " · " style, the two views' only difference here. */
500
- private hintLine(kind: "list" | "detail", stoppable: boolean, steerable: boolean): string {
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();