@nklisch/pi-enhanced 0.2.7 → 0.3.1

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 (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +27 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +76 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +118 -104
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +488 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +412 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -1,130 +1,46 @@
1
- /**
2
- * result-renderer.ts — Pure per-status rendering functions for Agent tool results.
3
- *
4
- * All functions are stateless: they receive AgentDetails and a Theme, returning
5
- * formatted strings. No SDK types, no timers, no side effects.
6
- * Consumed by the renderResult hook in agent-tool.ts.
7
- */
1
+ /** Pure presentation for the joined/detached launch result. */
8
2
 
9
3
  import type { AgentDetails, Theme } from "#src/ui/display";
10
4
  import { formatModelThinking, formatMs, formatTurns, SPINNER } from "#src/ui/display";
11
5
 
12
- // ---- Dispatcher ----
13
-
14
- /** Dispatch to the per-status renderer based on details.status and isPartial. */
15
- export function renderAgentResult(
16
- details: AgentDetails,
17
- resultText: string,
18
- expanded: boolean,
19
- isPartial: boolean,
20
- theme: Theme,
21
- ): string {
22
- if (isPartial || details.status === "running") return renderRunning(details, theme);
23
- if (details.status === "background") return renderBackground(details, theme);
24
- if (details.status === "completed" || details.status === "steered")
25
- return renderCompleted(details, resultText, expanded, theme);
26
- if (details.status === "stopped") return renderStopped(details, theme);
27
- return renderFailed(details, theme);
6
+ export function renderAgentResult(details: AgentDetails, resultText: string, expanded: boolean, isPartial: boolean, theme: Theme): string {
7
+ if (isPartial || details.status === "running" || details.status === "queued") return renderRunning(details, theme);
8
+ if (details.status === "completed") return renderCompleted(details, resultText, expanded, theme);
9
+ if (details.status === "stopped") return renderStopped(details, theme);
10
+ return renderFailed(details, theme);
28
11
  }
29
12
 
30
- // ---- Per-status renderers ----
31
-
32
- /** Render running/partial status: spinner + stats + activity line. */
33
13
  export function renderRunning(details: AgentDetails, theme: Theme): string {
34
- const frame = SPINNER[details.spinnerFrame ?? 0];
35
- const s = renderStats(details, theme);
36
- const duration = theme.fg("dim", formatMs(details.durationMs));
37
- let line = theme.fg("accent", frame) + (s ? " " + s : "");
38
- line += `${s ? " " + theme.fg("dim", "·") + " " : " "}${duration}`;
39
- line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking\u2026"}`);
40
- return line;
14
+ const frame = SPINNER[details.spinnerFrame ?? 0];
15
+ const stats = renderStats(details, theme);
16
+ const duration = theme.fg("dim", formatMs(details.durationMs));
17
+ return theme.fg("accent", frame) + (stats ? " " + stats : "") + " " + duration + "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
41
18
  }
42
19
 
43
- /** Render background launch status. */
44
- export function renderBackground(details: AgentDetails, theme: Theme): string {
45
- const identity = [
46
- formatModelThinking(details.modelName ?? "unknown model", details.thinkingLevel),
47
- formatMs(details.durationMs),
48
- ].join(" · ");
49
- return theme.fg("dim", ` \u23BF Running in background (ID: ${details.agentId}) · ${identity}`);
20
+ export function renderCompleted(details: AgentDetails, resultText: string, expanded: boolean, theme: Theme): string {
21
+ let line = theme.fg("success", "✓") + " " + renderStats(details, theme) + " " + theme.fg("dim", "·") + " " + theme.fg("dim", formatMs(details.durationMs));
22
+ if (expanded) {
23
+ for (const item of resultText.split("\n").slice(0, 50)) line += "\n" + theme.fg("dim", ` ${item}`);
24
+ if (resultText.split("\n").length > 50) line += "\n" + theme.fg("muted", " ... (output truncated; use get_subagent_result for the bounded final output)");
25
+ } else {
26
+ line += "\n" + theme.fg("dim", ` ${details.terminalReason === "turn_limit_graceful" ? "Completed (turn limit)" : "Done"}`);
27
+ }
28
+ return line;
50
29
  }
51
30
 
52
- /** Render completed or steered status with optional expanded result text. */
53
- export function renderCompleted(
54
- details: AgentDetails,
55
- resultText: string,
56
- expanded: boolean,
57
- theme: Theme,
58
- ): string {
59
- const duration = formatMs(details.durationMs);
60
- const isSteered = details.status === "steered";
61
- const icon = isSteered ? theme.fg("warning", "\u2713") : theme.fg("success", "\u2713");
62
- const s = renderStats(details, theme);
63
- let line = icon + (s ? " " + s : "");
64
- line += " " + theme.fg("dim", "\u00B7") + " " + theme.fg("dim", duration);
65
-
66
- if (expanded) {
67
- if (resultText) {
68
- const lines = resultText.split("\n").slice(0, 50);
69
- for (const l of lines) {
70
- line += "\n" + theme.fg("dim", ` ${l}`);
71
- }
72
- if (resultText.split("\n").length > 50) {
73
- line +=
74
- "\n" +
75
- theme.fg(
76
- "muted",
77
- " ... (use get_subagent_result with verbose for full output)",
78
- );
79
- }
80
- }
81
- } else {
82
- const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
83
- line += "\n" + theme.fg("dim", ` \u23BF ${doneText}`);
84
- }
85
- return line;
86
- }
87
-
88
- /** Render stopped status: dim stop icon + stats + "Stopped". */
89
31
  export function renderStopped(details: AgentDetails, theme: Theme): string {
90
- const s = renderStats(details, theme);
91
- let line = theme.fg("dim", "\u25A0") + (s ? " " + s : "");
92
- line += ` ${theme.fg("dim", "·")} ${theme.fg("dim", formatMs(details.durationMs))}`;
93
- line += "\n" + theme.fg("dim", " \u23BF Stopped");
94
- return line;
32
+ return theme.fg("dim", "■") + " " + renderStats(details, theme) + " " + theme.fg("dim", "·") + " " + theme.fg("dim", formatMs(details.durationMs)) + "\n" + theme.fg("dim", ` ⎿ Stopped${details.terminalReason ? ` (${details.terminalReason.replaceAll("_", " ")})` : ""}`);
95
33
  }
96
34
 
97
- /** Render error or aborted status: error icon + stats + status message. */
98
35
  export function renderFailed(details: AgentDetails, theme: Theme): string {
99
- const s = renderStats(details, theme);
100
- let line = theme.fg("error", "\u2717") + (s ? " " + s : "");
101
- line += ` ${theme.fg("dim", "·")} ${theme.fg("dim", formatMs(details.durationMs))}`;
102
-
103
- if (details.status === "error") {
104
- line += "\n" + theme.fg("error", ` \u23BF Error: ${details.error ?? "unknown"}`);
105
- } else {
106
- line += "\n" + theme.fg("warning", " \u23BF Aborted (max turns exceeded)");
107
- }
108
- return line;
36
+ return theme.fg("error", "✗") + " " + renderStats(details, theme) + " " + theme.fg("dim", "·") + " " + theme.fg("dim", formatMs(details.durationMs)) + "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
109
37
  }
110
38
 
111
- // ---- Shared helper ----
112
-
113
- /**
114
- * Build the stats string: "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k token".
115
- * Returns an empty string when all fields are absent or zero.
116
- */
117
39
  export function renderStats(details: AgentDetails, theme: Theme): string {
118
- const parts: string[] = [];
119
- parts.push(formatModelThinking(details.modelName ?? "unknown model", details.thinkingLevel));
120
- if (details.tags) parts.push(...details.tags.filter((tag) => !tag.startsWith("thinking: ")));
121
- if (details.turnCount != null && details.turnCount > 0) {
122
- parts.push(formatTurns(details.turnCount, details.maxTurns));
123
- }
124
- if (details.toolUses > 0)
125
- parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
126
- if (details.tokens) parts.push(details.tokens);
127
- return parts
128
- .map((p) => theme.fg("dim", p))
129
- .join(" " + theme.fg("dim", "\u00B7") + " ");
40
+ const parts: string[] = [formatModelThinking(details.modelName ?? "unknown model", details.thinkingLevel)];
41
+ if (details.tags) parts.push(...details.tags.filter((tag) => !tag.startsWith("thinking: ")));
42
+ if (details.turnCount != null && details.turnCount > 0) parts.push(formatTurns(details.turnCount, details.maxTurns));
43
+ if (details.toolUses > 0) parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
44
+ if (details.tokens) parts.push(details.tokens);
45
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
130
46
  }
@@ -0,0 +1,105 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { SubagentMode } from "#src/types";
4
+ import type { ResumeOutcome } from "#src/lifecycle/subagent-manager";
5
+ import type { Subagent } from "#src/lifecycle/subagent";
6
+ import { buildDetails, formatLifetimeTokens, textResult } from "#src/tools/helpers";
7
+ import { formatModelThinking, formatMs } from "#src/ui/display";
8
+
9
+ export interface ResumeToolManager {
10
+ resume(id: string, prompt: string, mode: SubagentMode, timeoutSeconds: number | undefined, signal?: AbortSignal, onReserved?: (record: Subagent) => void): Promise<ResumeOutcome>;
11
+ getRecord(id: string): Subagent | undefined;
12
+ }
13
+
14
+ export class ResumeTool {
15
+ constructor(private readonly manager: ResumeToolManager) {}
16
+
17
+ async execute(_toolCallId: string, params: { agent_id: string; prompt: string; mode?: SubagentMode; timeout_seconds?: number }, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, _ctx: unknown) {
18
+ if (params.timeout_seconds != null && (!Number.isInteger(params.timeout_seconds) || params.timeout_seconds <= 0)) {
19
+ return textResult("timeout_seconds must be a positive integer");
20
+ }
21
+ const mode = params.mode ?? "detached";
22
+ let spinnerFrame = 0;
23
+ const timer = setInterval(() => {
24
+ if (!onUpdate || mode !== "joined") return;
25
+ const record = this.manager.getRecord(params.agent_id);
26
+ if (!record) return;
27
+ onUpdate({ content: [{ type: "text", text: `${record.toolUses} tool uses...` }], details: buildDetails({
28
+ displayName: record.type, description: record.description, subagentType: record.type,
29
+ modelName: record.modelLabel, thinkingLevel: record.effectiveThinkingLevel, tags: [`mode: ${record.mode}`],
30
+ }, record, {
31
+ status: record.status,
32
+ terminalReason: record.stateTerminalReason,
33
+ durationMs: record.activeRuntimeMs,
34
+ spinnerFrame: spinnerFrame++,
35
+ }) });
36
+ }, 500);
37
+ let outcome: ResumeOutcome;
38
+ try {
39
+ outcome = await this.manager.resume(params.agent_id, params.prompt, mode, params.timeout_seconds, signal, (record) => {
40
+ if (onUpdate && mode === "joined") onUpdate({ content: [{ type: "text", text: "Resuming..." }], details: buildDetails({
41
+ displayName: record.type, description: record.description, subagentType: record.type,
42
+ modelName: record.modelLabel, thinkingLevel: record.effectiveThinkingLevel, tags: [`mode: ${record.mode}`],
43
+ }, record, {
44
+ status: record.status,
45
+ terminalReason: record.stateTerminalReason,
46
+ durationMs: record.activeRuntimeMs,
47
+ }) });
48
+ });
49
+ } finally {
50
+ clearInterval(timer);
51
+ }
52
+ if (outcome.kind === "not_found") return textResult(`Agent not found: "${outcome.agentId}". It may have been cleaned up.`);
53
+ if (outcome.kind === "wrong_state") return textResult(`Agent "${outcome.agentId}" cannot be resumed (status: ${outcome.status}). It must have a retained settled session.`);
54
+
55
+ const record = outcome.kind === "joined" ? outcome.record : this.manager.getRecord(outcome.agentId);
56
+ if (!record) return textResult(`Agent not found after resume: "${params.agent_id}".`);
57
+ if (outcome.kind === "detached") {
58
+ return textResult(`Resume detached for agent ${record.id}.\nRun ID: ${record.runId}\nMode: detached\nCompletion will send one notification.`, buildDetails({
59
+ displayName: record.type,
60
+ description: record.description,
61
+ subagentType: record.type,
62
+ modelName: record.modelLabel,
63
+ thinkingLevel: record.effectiveThinkingLevel,
64
+ tags: [`mode: ${record.mode}`],
65
+ }, record, {
66
+ status: record.status,
67
+ terminalReason: record.stateTerminalReason,
68
+ durationMs: record.activeRuntimeMs,
69
+ }));
70
+ }
71
+ record.markConsumed();
72
+ return textResult(`Agent ${record.id} ${record.status}.\nRun ID: ${record.runId}\nModel: ${formatModelThinking(record.modelLabel, record.effectiveThinkingLevel)}\nRuntime: ${formatMs(record.activeRuntimeMs)}\nReason: ${record.stateTerminalReason ?? "unknown"}\n\n${boundResult(record.result, record.outputFile)}`, buildDetails({
73
+ displayName: record.type,
74
+ description: record.description,
75
+ subagentType: record.type,
76
+ modelName: record.modelLabel,
77
+ thinkingLevel: record.effectiveThinkingLevel,
78
+ tags: [`mode: ${record.mode}`],
79
+ }, record, { tokens: formatLifetimeTokens(record), durationMs: record.activeRuntimeMs, terminalReason: record.stateTerminalReason }));
80
+ }
81
+
82
+ toToolDefinition() {
83
+ return defineTool({
84
+ name: "resume_subagent" as const,
85
+ label: "Resume Subagent",
86
+ promptSnippet: "resume_subagent: Continue a retained subagent session.",
87
+ description: "Continue a retained settled subagent session. Delivery defaults to detached; joined waits for the resumed run to settle.",
88
+ parameters: Type.Object({
89
+ agent_id: Type.String({ description: "The retained subagent ID to resume." }),
90
+ prompt: Type.String({ description: "The next task or instruction." }),
91
+ mode: Type.Optional(Type.Union([Type.Literal("joined"), Type.Literal("detached")], { description: "Result delivery mode. Defaults to detached." })),
92
+ timeout_seconds: Type.Optional(Type.Integer({ description: "Active runtime deadline in seconds; queued time is excluded.", minimum: 1 })),
93
+ }),
94
+ execute: (toolCallId: string, params: { agent_id: string; prompt: string; mode?: SubagentMode; timeout_seconds?: number }, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) => this.execute(toolCallId, params, signal, onUpdate as ((update: unknown) => void) | undefined, ctx),
95
+ });
96
+ }
97
+ }
98
+
99
+ const MAX_RESULT_OUTPUT = 12_000;
100
+ function boundResult(result: string | undefined, outputFile: string | undefined): string {
101
+ const output = result?.trim() || "No output.";
102
+ return output.length > MAX_RESULT_OUTPUT
103
+ ? output.slice(0, MAX_RESULT_OUTPUT) + `\n\nOutput truncated. Full transcript: ${outputFile ?? "unavailable"}`
104
+ : output;
105
+ }
@@ -15,7 +15,7 @@ import type { ModelRegistry } from "#src/session/model-resolver";
15
15
  import { formatModelLabel } from "#src/session/model-label";
16
16
  import { resolveInvocationModel } from "#src/session/model-resolver";
17
17
  import { resolveEffectiveThinkingLevel } from "#src/session/thinking-level";
18
- import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
18
+ import type { AgentInvocation, SubagentMode, SubagentType, ThinkingLevel } from "#src/types";
19
19
  import {
20
20
  type AgentDetails,
21
21
  buildInvocationTags,
@@ -50,7 +50,8 @@ export interface SpawnExecution {
50
50
  /** Exact level passed to the child session after model-capability clamping. */
51
51
  effectiveThinkingLevel: ThinkingLevel;
52
52
  inheritContext: boolean;
53
- runInBackground: boolean;
53
+ mode: SubagentMode;
54
+ timeoutSeconds?: number;
54
55
  agentInvocation: AgentInvocation;
55
56
  }
56
57
 
@@ -88,6 +89,13 @@ export function resolveSpawnConfig(
88
89
  readonly fallbackSubagent?: string | false;
89
90
  },
90
91
  ): ResolvedSpawnConfig | SpawnConfigError {
92
+ const removedField = ["run_in_background", "foreground", "resume"].find((field) =>
93
+ Object.prototype.hasOwnProperty.call(params, field),
94
+ );
95
+ if (removedField) {
96
+ return { error: `Removed field "${removedField}"; use mode: joined|detached${removedField === "resume" ? " and resume_subagent" : ""}.` };
97
+ }
98
+
91
99
  const rawType = params.subagent_type as SubagentType;
92
100
  const typeResolution = resolveDispatchAgentType(
93
101
  rawType,
@@ -122,21 +130,30 @@ export function resolveSpawnConfig(
122
130
  modelInfo.parentThinkingLevel,
123
131
  );
124
132
  const inheritContext = resolvedConfig.inheritContext;
125
- const runInBackground = resolvedConfig.runInBackground;
133
+ const mode = resolvedConfig.mode;
134
+ if (mode !== "joined" && mode !== "detached") {
135
+ return { error: "mode must be joined or detached" };
136
+ }
137
+ const maxTurns = resolvedConfig.maxTurns ?? settings.defaultMaxTurns;
138
+ if (maxTurns != null && (!Number.isInteger(maxTurns) || maxTurns < 0)) {
139
+ return { error: "max_turns must be a non-negative integer" };
140
+ }
141
+ if (resolvedConfig.timeoutSeconds != null && (!Number.isInteger(resolvedConfig.timeoutSeconds) || resolvedConfig.timeoutSeconds <= 0)) {
142
+ return { error: "timeout_seconds must be a positive integer" };
143
+ }
126
144
 
127
145
  // Every status surface shows the exact effective model, including inheritance.
128
146
  const modelName = formatModelLabel(model);
129
147
 
130
- const effectiveMaxTurns = normalizeMaxTurns(
131
- resolvedConfig.maxTurns ?? settings.defaultMaxTurns,
132
- );
148
+ const effectiveMaxTurns = normalizeMaxTurns(maxTurns);
133
149
 
134
150
  const agentInvocation: AgentInvocation = {
135
151
  modelName,
136
152
  thinking,
137
153
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
138
154
  inheritContext,
139
- runInBackground,
155
+ mode,
156
+ timeoutSeconds: resolvedConfig.timeoutSeconds,
140
157
  };
141
158
 
142
159
  const modeLabel = getPromptModeLabel(subagentType, registry);
@@ -162,7 +179,8 @@ export function resolveSpawnConfig(
162
179
  thinking,
163
180
  effectiveThinkingLevel,
164
181
  inheritContext,
165
- runInBackground,
182
+ mode,
183
+ timeoutSeconds: resolvedConfig.timeoutSeconds,
166
184
  agentInvocation,
167
185
  },
168
186
  presentation: { modelName, agentTags, detailBase },
@@ -1,117 +1,33 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
4
- import type { SteerOutcome, Subagent } from "#src/types";
5
- import { formatDuration, formatModelThinking } from "#src/ui/display";
3
+ import type { ManagerSteerOutcome } from "#src/lifecycle/subagent-manager";
4
+ import { textResult } from "#src/tools/helpers";
6
5
 
7
- // ---- Deps interfaces ----
8
-
9
- export interface SteerToolManager {
10
- getRecord(id: string): Subagent | undefined;
11
- }
12
-
13
- export interface SteerToolEvents {
14
- emit(name: string, data: unknown): void;
15
- }
16
-
17
- // ---- Class ----
6
+ export interface SteerToolManager { steer(id: string, message: string): Promise<ManagerSteerOutcome>; }
7
+ export interface SteerToolEvents { emit(name: string, data: unknown): void; }
18
8
 
19
9
  export class SteerTool {
20
- constructor(
21
- private readonly manager: SteerToolManager,
22
- private readonly events: SteerToolEvents,
23
- ) {}
24
-
25
- async execute(
26
- _toolCallId: string,
27
- params: { agent_id: string; message: string },
28
- _signal: AbortSignal,
29
- _onUpdate: unknown,
30
- _ctx: unknown,
31
- ) {
32
- const record = this.manager.getRecord(params.agent_id);
33
- if (!record) {
34
- return textResult(
35
- `Agent not found: "${params.agent_id}". It may have been cleaned up.`,
36
- );
37
- }
38
-
39
- let outcome: SteerOutcome;
40
- try {
41
- outcome = await record.steer(params.message);
42
- } catch (err) {
43
- return textResult(
44
- `Failed to steer agent: ${err instanceof Error ? err.message : String(err)}\n`
45
- + this.renderRunIdentity(record),
46
- );
47
- }
48
-
49
- switch (outcome.kind) {
50
- case "rejected":
51
- return textResult(
52
- `Agent "${params.agent_id}" is not running (status: ${outcome.status}). Cannot steer a non-running agent.\n`
53
- + this.renderRunIdentity(record),
54
- );
55
- case "buffered":
56
- this.events.emit("subagents:steered", { id: record.id, message: params.message });
57
- return textResult(
58
- `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.\n`
59
- + this.renderRunIdentity(record),
60
- );
61
- case "delivered":
62
- this.events.emit("subagents:steered", { id: record.id, message: params.message });
63
- return this.renderDelivered(record);
64
- }
65
- }
66
-
67
- /** Render the success message with live state for a delivered steer. */
68
- private renderDelivered(record: Subagent) {
69
- const tokens = formatLifetimeTokens(record);
70
- const contextPercent = record.getContextPercent();
71
- const stateParts: string[] = [formatModelThinking(record.modelLabel, record.effectiveThinkingLevel), formatDuration(record.startedAt, record.completedAt)];
72
- if (tokens) stateParts.push(tokens);
73
- stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
74
- if (contextPercent !== null)
75
- stateParts.push(`context ${Math.round(contextPercent)}% full`);
76
- if (record.compactionCount)
77
- stateParts.push(
78
- `${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
79
- );
80
- return textResult(
81
- `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
82
- `Current state: ${stateParts.join(" · ")}`,
83
- );
84
- }
85
-
86
- private renderRunIdentity(record: Subagent): string {
87
- return `Model: ${formatModelThinking(record.modelLabel, record.effectiveThinkingLevel)}\nRuntime: ${formatDuration(record.startedAt, record.completedAt)}`;
88
- }
89
-
90
- toToolDefinition() {
91
- return defineTool({
92
- name: "steer_subagent" as const,
93
- label: "Steer Agent",
94
- promptSnippet:
95
- "steer_subagent: Send a mid-run message to redirect a running background agent.",
96
- description:
97
- "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
98
- "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
99
- parameters: Type.Object({
100
- agent_id: Type.String({
101
- description: "The agent ID to steer (must be currently running).",
102
- }),
103
- message: Type.String({
104
- description:
105
- "The steering message to send. This will appear as a user message in the agent's conversation.",
106
- }),
107
- }),
108
- execute: (
109
- toolCallId: string,
110
- params: { agent_id: string; message: string },
111
- signal: AbortSignal,
112
- onUpdate: unknown,
113
- ctx: unknown,
114
- ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
115
- });
116
- }
10
+ constructor(private readonly manager: SteerToolManager, private readonly events: SteerToolEvents) {}
11
+
12
+ async execute(_toolCallId: string, params: { agent_id: string; message: string }, _signal: AbortSignal, _onUpdate: unknown, _ctx: unknown) {
13
+ const outcome = await this.manager.steer(params.agent_id, params.message);
14
+ if (outcome.kind === "not_found") return textResult(`Agent not found: "${outcome.agentId}". It may have been cleaned up.`, outcome);
15
+ if (outcome.kind === "rejected") return textResult(`Agent "${params.agent_id}" cannot be steered (status: ${outcome.status}).`, { ...outcome, agentId: params.agent_id });
16
+ this.events.emit("subagents:steered", { id: params.agent_id, runId: outcome.runId, outcome: outcome.kind, message: params.message });
17
+ return textResult(`Steering message ${outcome.kind} for agent ${params.agent_id}.\nRun ID: ${outcome.runId}.`, { ...outcome, agentId: params.agent_id });
18
+ }
19
+
20
+ toToolDefinition() {
21
+ return defineTool({
22
+ name: "steer_subagent" as const,
23
+ label: "Steer Subagent",
24
+ promptSnippet: "steer_subagent: Send a message to a running subagent.",
25
+ description: "Send a steering message to a running subagent. The structured outcome distinguishes delivered, buffered, rejected, and not-found cases.",
26
+ parameters: Type.Object({
27
+ agent_id: Type.String({ description: "The running subagent ID." }),
28
+ message: Type.String({ description: "The message to add to the subagent's conversation." }),
29
+ }),
30
+ execute: (toolCallId: string, params: { agent_id: string; message: string }, signal: AbortSignal, onUpdate: unknown, ctx: unknown) => this.execute(toolCallId, params, signal, onUpdate, ctx),
31
+ });
32
+ }
117
33
  }
@@ -0,0 +1,53 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { StopOutcome } from "#src/lifecycle/subagent-manager";
4
+ import type { Subagent } from "#src/lifecycle/subagent";
5
+ import { buildDetails, formatLifetimeTokens, textResult } from "#src/tools/helpers";
6
+ import { formatModelThinking, formatMs } from "#src/ui/display";
7
+
8
+ export interface StopToolManager {
9
+ stop(id: string, settlementTimeoutSeconds?: number, waitSignal?: AbortSignal): Promise<StopOutcome>;
10
+ }
11
+
12
+ export class StopTool {
13
+ constructor(private readonly manager: StopToolManager) {}
14
+
15
+ async execute(_toolCallId: string, params: { agent_id: string; settlement_timeout_seconds?: number }, signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) {
16
+ const timeout = params.settlement_timeout_seconds ?? 5;
17
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30) return textResult("settlement_timeout_seconds must be an integer from 1 to 30");
18
+ const outcome = await this.manager.stop(params.agent_id, timeout, signal);
19
+ if (outcome.kind === "not_found") return textResult(`Agent not found: "${outcome.agentId}". It may have been cleaned up.`, { kind: outcome.kind, agentId: outcome.agentId });
20
+ if (outcome.kind === "already_terminal") return textResult(`Agent ${outcome.agentId} is already terminal (${outcome.record.status}, ${outcome.record.stateTerminalReason ?? "unknown"}).`, renderDetails(outcome.record));
21
+ if (outcome.kind === "stop_pending") return textResult(`Stop requested for agent ${outcome.agentId}, but its run has not settled within ${timeout}s. It remains active; no hard kill was attempted.\nRun ID: ${outcome.runId}\nReason: ${outcome.reason}`, renderDetails(outcome.record));
22
+ return textResult(`Agent ${outcome.agentId} stopped.\nRun ID: ${outcome.runId}\nReason: ${outcome.reason}\nRuntime: ${formatMs(outcome.record.activeRuntimeMs)}`, renderDetails(outcome.record));
23
+ }
24
+
25
+ toToolDefinition() {
26
+ return defineTool({
27
+ name: "stop_subagent" as const,
28
+ label: "Stop Subagent",
29
+ promptSnippet: "stop_subagent: Request cooperative cancellation of a subagent.",
30
+ description: "Stop queued work immediately or request cooperative cancellation of a live subagent. The result distinguishes settled, already-terminal, not-found, and stop-pending outcomes; it never hard-kills JavaScript.",
31
+ parameters: Type.Object({
32
+ agent_id: Type.String({ description: "The subagent ID to stop." }),
33
+ settlement_timeout_seconds: Type.Optional(Type.Integer({ description: "How long to wait for cooperative settlement (1-30 seconds, default 5).", minimum: 1, maximum: 30 })),
34
+ }),
35
+ execute: (toolCallId: string, params: { agent_id: string; settlement_timeout_seconds?: number }, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) => this.execute(toolCallId, params, signal, onUpdate, ctx),
36
+ });
37
+ }
38
+ }
39
+
40
+ function renderDetails(record: Subagent) {
41
+ return buildDetails({
42
+ displayName: record.type,
43
+ description: record.description,
44
+ subagentType: record.type,
45
+ modelName: record.modelLabel,
46
+ thinkingLevel: record.effectiveThinkingLevel,
47
+ tags: [`mode: ${record.mode}`],
48
+ }, record, {
49
+ terminalReason: record.stateTerminalReason,
50
+ tokens: formatLifetimeTokens(record),
51
+ durationMs: record.activeRuntimeMs,
52
+ });
53
+ }
@@ -33,6 +33,9 @@ export interface SubscribableSession {
33
33
  /** Agent type: any string name (built-in defaults or user-defined). */
34
34
  export type SubagentType = string;
35
35
 
36
+ /** Delivery mode for one initial or resumed run. */
37
+ export type SubagentMode = "joined" | "detached";
38
+
36
39
  /** UI display and agent listing — name, display name, description, prompt mode. */
37
40
  export interface AgentIdentity {
38
41
  name: string;
@@ -54,10 +57,12 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
54
57
  model?: string;
55
58
  thinking?: ThinkingLevel;
56
59
  maxTurns?: number;
57
- /** Default for spawn: fork parent conversation. undefined = caller decides. */
60
+ /** Active runtime deadline in seconds for launch/resume. */
61
+ timeoutSeconds?: number;
62
+ /** Default for launch/resume: fork parent conversation. undefined = caller decides. */
58
63
  inheritContext?: boolean;
59
- /** Default for spawn: run in background. undefined = caller decides. */
60
- runInBackground?: boolean;
64
+ /** Default delivery mode for launch/resume. */
65
+ mode?: SubagentMode;
61
66
  /** One-line usage guideline for the subagent tool's Guidelines: block. Omitted — no guideline line. */
62
67
  toolGuideline?: string;
63
68
  /** true = this is an embedded default agent (informational) */
@@ -74,7 +79,8 @@ export interface AgentInvocation {
74
79
  thinking?: ThinkingLevel;
75
80
  maxTurns?: number;
76
81
  inheritContext?: boolean;
77
- runInBackground?: boolean;
82
+ mode?: SubagentMode;
83
+ timeoutSeconds?: number;
78
84
  }
79
85
 
80
86
  /**
@@ -115,7 +121,7 @@ export interface ParentSessionInfo {
115
121
  parentSessionFile?: string;
116
122
  /** Session ID of the parent agent (stored in the child session's parentSession header). */
117
123
  parentSessionId?: string;
118
- /** Tool call ID for background notification wiring. Exposed on the record via Subagent.toolCallId. */
124
+ /** Tool call ID for detached completion notification wiring. Exposed on the record via Subagent.toolCallId. */
119
125
  toolCallId?: string;
120
126
  }
121
127