@nklisch/pi-enhanced 0.2.6 → 0.3.0

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 +25 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +75 -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 +112 -105
  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 -223
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -683
  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 -119
  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 +327 -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 -92
  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 +330 -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
@@ -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
 
@@ -87,14 +87,14 @@ export class AgentWidget implements SubagentManagerObserver {
87
87
  private widgetFrame = 0;
88
88
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
89
89
  /**
90
- * Bounded reactive read model: lifecycle callbacks add active background
90
+ * Bounded reactive read model: lifecycle callbacks add active detached
91
91
  * records and the small terminal linger set; render ticks never ask the
92
92
  * manager to clone or sort its retained history.
93
93
  */
94
- private readonly backgroundAgents = new Map<string, Subagent>();
94
+ private readonly detachedAgents = new Map<string, Subagent>();
95
95
  /** Tracks how many turns each finished agent has survived. */
96
96
  private finishedTurnAge = new Map<string, number>();
97
- /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
97
+ /** How many extra turns errors/stopped agents linger (completed agents clear after 1 turn). */
98
98
  private static readonly ERROR_LINGER_TURNS = 2;
99
99
  /** Pi renders the whole component tree for each requestRender call. */
100
100
  private static readonly STATUS_REFRESH_INTERVAL_MS = 500;
@@ -122,7 +122,7 @@ export class AgentWidget implements SubagentManagerObserver {
122
122
  // Lifecycle events can arrive before the first tool gives us a UI context.
123
123
  // Refresh when that context becomes available, but let update() decide
124
124
  // whether any active record actually needs an animation timer.
125
- if (contextChanged && this.uiCtx && this.backgroundAgents.size > 0) this.refresh();
125
+ if (contextChanged && this.uiCtx && this.detachedAgents.size > 0) this.refresh();
126
126
  }
127
127
 
128
128
  /**
@@ -131,13 +131,13 @@ export class AgentWidget implements SubagentManagerObserver {
131
131
  */
132
132
  onTurnStart() {
133
133
  // Age terminal records and remove them before the next render. Completed
134
- // records linger for one turn; errors and aborted records linger for two.
134
+ // records linger for one turn; errors and stopped records linger for two.
135
135
  for (const [id, age] of this.finishedTurnAge) {
136
- const record = this.backgroundAgents.get(id);
136
+ const record = this.detachedAgents.get(id);
137
137
  const nextAge = age + 1;
138
138
  if (!record || nextAge >= this.maxFinishedAge(record.status)) {
139
139
  this.finishedTurnAge.delete(id);
140
- this.backgroundAgents.delete(id);
140
+ this.detachedAgents.delete(id);
141
141
  } else {
142
142
  this.finishedTurnAge.set(id, nextAge);
143
143
  }
@@ -149,47 +149,47 @@ export class AgentWidget implements SubagentManagerObserver {
149
149
 
150
150
  /** A subagent started running — ensure the update loop is live and render. */
151
151
  onSubagentStarted(record: Subagent) {
152
- if (!this.trackBackground(record)) return;
152
+ if (!this.trackDetached(record)) return;
153
153
  this.finishedTurnAge.delete(record.id);
154
154
  this.startLoop();
155
155
  }
156
156
 
157
- /** A background subagent was created (queued) — ensure the loop is live and render. */
157
+ /** A detached subagent was created (queued) — ensure the loop is live and render. */
158
158
  onSubagentCreated(record: Subagent) {
159
- if (!this.trackBackground(record)) return;
159
+ if (!this.trackDetached(record)) return;
160
160
  this.finishedTurnAge.delete(record.id);
161
161
  this.startLoop();
162
162
  }
163
163
 
164
164
  /** A subagent completed — seed its bounded linger entry and render. */
165
165
  onSubagentCompleted(record: Subagent) {
166
- if (!this.trackBackground(record)) return;
166
+ if (!this.trackDetached(record)) return;
167
167
  this.finishedTurnAge.set(record.id, 0);
168
168
  this.refresh();
169
169
  }
170
170
 
171
171
  /** A resumed subagent started — ensure the update loop is live and render. */
172
172
  onSubagentResumedStarted(record: Subagent) {
173
- if (!this.trackBackground(record)) return;
173
+ if (!this.trackDetached(record)) return;
174
174
  this.finishedTurnAge.delete(record.id);
175
175
  this.startLoop();
176
176
  }
177
177
 
178
178
  /** A resumed subagent settled — seed its terminal linger entry. */
179
179
  onSubagentResumed(record: Subagent) {
180
- if (!this.trackBackground(record)) return;
180
+ if (!this.trackDetached(record)) return;
181
181
  this.finishedTurnAge.set(record.id, 0);
182
182
  this.refresh();
183
183
  }
184
184
 
185
185
  /** A subagent's session compacted — render to refresh the compaction count. */
186
186
  onSubagentCompacted(record: Subagent, _info: CompactionInfo) {
187
- if (this.trackBackground(record)) this.refresh();
187
+ if (this.trackDetached(record)) this.refresh();
188
188
  }
189
189
 
190
190
  /** Remove terminal state when the manager clears a parent session. */
191
191
  onSubagentCleared(record: Subagent) {
192
- if (!this.backgroundAgents.delete(record.id)) return;
192
+ if (!this.detachedAgents.delete(record.id)) return;
193
193
  this.finishedTurnAge.delete(record.id);
194
194
  this.refresh();
195
195
  }
@@ -226,19 +226,23 @@ export class AgentWidget implements SubagentManagerObserver {
226
226
  return ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
227
227
  }
228
228
 
229
- /** Add a record only when its immutable invocation snapshot marks it background. */
230
- private trackBackground(record: Subagent): boolean {
231
- if (record.invocation?.runInBackground !== true) return false;
232
- this.backgroundAgents.set(record.id, record);
229
+ /** Add a record only when its current run mode is detached. */
230
+ private trackDetached(record: Subagent): boolean {
231
+ if (record.mode !== "detached") {
232
+ this.detachedAgents.delete(record.id);
233
+ this.finishedTurnAge.delete(record.id);
234
+ return false;
235
+ }
236
+ this.detachedAgents.set(record.id, record);
233
237
  return true;
234
238
  }
235
239
 
236
240
  /** Drop terminal entries whose turn-based linger window has already elapsed. */
237
241
  private pruneExpiredFinished(): void {
238
- for (const [id, record] of this.backgroundAgents) {
242
+ for (const [id, record] of this.detachedAgents) {
239
243
  if (record.isActive()) continue;
240
244
  if (record.completedAt == null || !this.shouldShowFinished(id, record.status)) {
241
- this.backgroundAgents.delete(id);
245
+ this.detachedAgents.delete(id);
242
246
  this.finishedTurnAge.delete(id);
243
247
  }
244
248
  }
@@ -250,12 +254,14 @@ export class AgentWidget implements SubagentManagerObserver {
250
254
  id: record.id,
251
255
  type: record.type,
252
256
  status: record.status,
257
+ terminalReason: record.stateTerminalReason,
253
258
  description: record.description,
254
259
  modelLabel: record.modelLabel,
255
260
  thinkingLevel: record.effectiveThinkingLevel,
256
261
  toolUses: record.toolUses,
257
262
  startedAt: record.startedAt,
258
263
  completedAt: record.completedAt,
264
+ activeRuntimeMs: record.activeRuntimeMs,
259
265
  error: record.error,
260
266
  lifetimeUsage: record.lifetimeUsage,
261
267
  compactionCount: record.compactionCount,
@@ -270,7 +276,7 @@ export class AgentWidget implements SubagentManagerObserver {
270
276
  /** Delegate rendering to the pure widget-renderer module. */
271
277
  private renderWidget(tui: any, theme: Theme): string[] {
272
278
  return renderWidgetLines({
273
- agents: [...this.backgroundAgents.values()].map(r => this.toWidgetAgent(r)),
279
+ agents: [...this.detachedAgents.values()].map(r => this.toWidgetAgent(r)),
274
280
  registry: this.registry,
275
281
  spinnerFrame: this.widgetFrame,
276
282
  terminalWidth: tui.terminal.columns,
@@ -295,7 +301,7 @@ export class AgentWidget implements SubagentManagerObserver {
295
301
  this.lastStatusText = undefined;
296
302
  }
297
303
  this.stopTimer();
298
- this.backgroundAgents.clear();
304
+ this.detachedAgents.clear();
299
305
  this.finishedTurnAge.clear();
300
306
  }
301
307
 
@@ -316,8 +322,8 @@ export class AgentWidget implements SubagentManagerObserver {
316
322
  if (!this.uiCtx) return;
317
323
 
318
324
  this.pruneExpiredFinished();
319
- const backgroundAgents = [...this.backgroundAgents.values()];
320
- const state = assembleWidgetState(backgroundAgents, (id, status) => this.shouldShowFinished(id, status));
325
+ const detachedAgents = [...this.detachedAgents.values()];
326
+ const state = assembleWidgetState(detachedAgents, (id, status) => this.shouldShowFinished(id, status));
321
327
 
322
328
  if (!state.hasActive && !state.hasFinished) {
323
329
  this.clearWidget();
@@ -329,7 +335,7 @@ export class AgentWidget implements SubagentManagerObserver {
329
335
  // static completion widget remains registered.
330
336
  if (state.hasActive) this.ensureTimer();
331
337
  else this.stopTimer();
332
- this.updateStatusBar(state, backgroundAgents);
338
+ this.updateStatusBar(state, detachedAgents);
333
339
  this.widgetFrame++;
334
340
 
335
341
  // Register widget callback once; subsequent updates use requestRender()
@@ -387,7 +393,7 @@ export class AgentWidget implements SubagentManagerObserver {
387
393
  this.widgetRegistered = false;
388
394
  this.tui = undefined;
389
395
  this.lastStatusText = undefined;
390
- this.backgroundAgents.clear();
396
+ this.detachedAgents.clear();
391
397
  this.finishedTurnAge.clear();
392
398
  }
393
399
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { AgentConfigLookup } from "#src/config/agent-types";
9
9
  import { GLYPHS } from "#src/ui/glyphs";
10
+ import type { SubagentTerminalReason } from "#src/lifecycle/subagent-state";
10
11
  import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
11
12
 
12
13
  // ---- Types ----
@@ -24,7 +25,8 @@ export interface AgentDetails {
24
25
  toolUses: number;
25
26
  tokens: string;
26
27
  durationMs: number;
27
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
28
+ status: "queued" | "running" | "completed" | "stopped" | "error";
29
+ terminalReason?: SubagentTerminalReason;
28
30
  /** Human-readable description of what the agent is currently doing. */
29
31
  activity?: string;
30
32
  /** Current spinner frame index (for animated running indicator). */
@@ -49,7 +51,7 @@ export interface AgentDetails {
49
51
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
50
52
 
51
53
  /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
52
- export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
54
+ export const ERROR_STATUSES = new Set(["error", "stopped"]);
53
55
 
54
56
  /** Tool name → human-readable action for activity descriptions. */
55
57
  const TOOL_DISPLAY: Record<string, string> = {
@@ -146,8 +148,9 @@ export function buildInvocationTags(
146
148
  if (!invocation) return { tags };
147
149
  if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
148
150
  if (invocation.inheritContext) tags.push("inherit context");
149
- if (invocation.runInBackground) tags.push("background");
151
+ if (invocation.mode) tags.push(`mode: ${invocation.mode}`);
150
152
  if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
153
+ if (invocation.timeoutSeconds != null) tags.push(`timeout: ${invocation.timeoutSeconds}s`);
151
154
  return { modelName: invocation.modelName, tags };
152
155
  }
153
156