@nklisch/pi-enhanced 0.2.7 → 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 +10 -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 +19 -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 +111 -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 +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 -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 +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
@@ -7,7 +7,7 @@ import { basename, join } from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "#src/config/agent-types";
9
9
  import { debugLog } from "#src/debug";
10
- import type { AgentConfig, ThinkingLevel } from "#src/types";
10
+ import type { AgentConfig, ThinkingLevel, SubagentMode } from "#src/types";
11
11
 
12
12
  /**
13
13
  * Scan for custom agent .md files from multiple locations.
@@ -60,6 +60,27 @@ function loadFromDir(
60
60
 
61
61
  try {
62
62
  const { frontmatter: fm, body } = parseFrontmatter(content);
63
+ if (fm.run_in_background !== undefined || fm.foreground !== undefined) {
64
+ const path = join(dir, file);
65
+ const removedFields = [
66
+ fm.run_in_background !== undefined ? "run_in_background" : undefined,
67
+ fm.foreground !== undefined ? "foreground" : undefined,
68
+ ].filter((field): field is string => field !== undefined).join(", ");
69
+ // Reserve the name as disabled so a removed field cannot silently fall
70
+ // through to a lower-priority file or an embedded default with a
71
+ // different delivery mode. The warning gives the owner an actionable
72
+ // migration path instead of making the refusal look like a missing file.
73
+ console.warn(`[pi-subagents] Ignoring ${path}: removed delivery field(s) ${removedFields}; use mode: joined|detached.`);
74
+ agents.set(name, {
75
+ name,
76
+ description: `Invalid agent definition: ${removedFields}`,
77
+ systemPrompt: "",
78
+ promptMode: "append",
79
+ enabled: false,
80
+ source,
81
+ });
82
+ continue;
83
+ }
63
84
  agents.set(name, {
64
85
  name,
65
86
  displayName: str(fm.display_name),
@@ -68,10 +89,11 @@ function loadFromDir(
68
89
  model: str(fm.model),
69
90
  thinking: str(fm.thinking) as ThinkingLevel | undefined,
70
91
  maxTurns: nonNegativeInt(fm.max_turns),
92
+ timeoutSeconds: positiveInt(fm.timeout_seconds),
71
93
  systemPrompt: body.trim(),
72
94
  promptMode: fm.prompt_mode === "replace" ? "replace" : "append",
73
95
  inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
74
- runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
96
+ mode: fm.mode === "joined" || fm.mode === "detached" ? fm.mode as SubagentMode : undefined,
75
97
  enabled: fm.enabled !== false, // default true; explicitly false disables
76
98
  source,
77
99
  });
@@ -91,9 +113,14 @@ function str(val: unknown): string | undefined {
91
113
  return typeof val === "string" ? val : undefined;
92
114
  }
93
115
 
94
- /** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
116
+ /** Extract a non-negative integer; 0 means unlimited for max_turns. */
95
117
  function nonNegativeInt(val: unknown): number | undefined {
96
- return typeof val === "number" && val >= 0 ? val : undefined;
118
+ return typeof val === "number" && Number.isInteger(val) && val >= 0 ? val : undefined;
119
+ }
120
+
121
+ /** Extract a positive integer for an active runtime deadline. */
122
+ function positiveInt(val: unknown): number | undefined {
123
+ return typeof val === "number" && Number.isInteger(val) && val > 0 ? val : undefined;
97
124
  }
98
125
 
99
126
  /**
@@ -17,7 +17,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
17
17
  description: "General-purpose agent for complex, multi-step tasks",
18
18
  toolGuideline: "- Use general-purpose for complex tasks that need file editing.",
19
19
  // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
20
- // inheritContext / runInBackground omitted — strategy fields, callers decide per-call.
20
+ // inheritContext / mode omitted — delivery strategy is selected by the caller.
21
21
  // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
22
22
  systemPrompt: "",
23
23
  promptMode: "append",
@@ -1,10 +1,11 @@
1
- import type { AgentConfig, ThinkingLevel } from "#src/types";
1
+ import type { AgentConfig, SubagentMode, ThinkingLevel } from "#src/types";
2
2
 
3
3
  interface AgentInvocationParams {
4
4
  model?: string;
5
5
  thinking?: string;
6
6
  max_turns?: number;
7
- run_in_background?: boolean;
7
+ mode?: SubagentMode;
8
+ timeout_seconds?: number;
8
9
  inherit_context?: boolean;
9
10
  }
10
11
 
@@ -16,15 +17,17 @@ export function resolveAgentInvocationConfig(
16
17
  modelFromParams: boolean;
17
18
  thinking?: ThinkingLevel;
18
19
  maxTurns?: number;
20
+ mode: SubagentMode;
21
+ timeoutSeconds?: number;
19
22
  inheritContext: boolean;
20
- runInBackground: boolean;
21
23
  } {
22
24
  return {
23
25
  modelInput: agentConfig?.model ?? params.model,
24
26
  modelFromParams: agentConfig?.model == null && params.model != null,
25
27
  thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
26
28
  maxTurns: agentConfig?.maxTurns ?? params.max_turns,
29
+ mode: agentConfig?.mode ?? params.mode ?? "detached",
30
+ timeoutSeconds: agentConfig?.timeoutSeconds ?? params.timeout_seconds,
27
31
  inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
28
- runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
29
32
  };
30
33
  }
@@ -4,8 +4,12 @@
4
4
  *
5
5
  * Tools:
6
6
  * Agent — LLM-callable: spawn a sub-agent
7
- * get_subagent_result — LLM-callable: check background agent status/result
8
- * steer_subagent — LLM-callable: send a steering message to a running agent
7
+ * get_subagent_result — LLM-callable: inspect bounded subagent status/result
8
+ * resume_subagent — LLM-callable: resume a retained settled session
9
+ * stop_subagent — LLM-callable: request cooperative cancellation
10
+ * list_subagents — LLM-callable: inspect fleet state
11
+ * steer_subagent — LLM-callable: send a steering message to a running agent
12
+ * query_subagent_session — LLM-callable: search a bounded child transcript
9
13
  *
10
14
  * Commands:
11
15
  */
@@ -41,7 +45,11 @@ import { deriveSubagentSessionDir } from "#src/session/session-dir";
41
45
  import { SettingsManager } from "#src/settings";
42
46
  import { AgentTool } from "#src/tools/agent-tool";
43
47
  import { GetResultTool } from "#src/tools/get-result-tool";
48
+ import { ListTool } from "#src/tools/list-tool";
49
+ import { QuerySessionTool } from "#src/tools/query-session-tool";
50
+ import { ResumeTool } from "#src/tools/resume-tool";
44
51
  import { SteerTool } from "#src/tools/steer-tool";
52
+ import { StopTool } from "#src/tools/stop-tool";
45
53
  import { AgentWidget } from "#src/ui/agent-widget";
46
54
  import { SessionNavigatorHandler } from "#src/ui/session-navigator";
47
55
  import { SubagentsSettingsHandler } from "#src/ui/subagents-settings";
@@ -105,7 +113,7 @@ export default function (pi: ExtensionAPI) {
105
113
  lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
106
114
  };
107
115
 
108
- // ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
116
+ // ConcurrencyLimiter: schedules run thunks FIFO against the limit.
109
117
  // It knows nothing about agents or the manager — dependency direction is strictly manager → limiter.
110
118
  const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
111
119
 
@@ -118,7 +126,7 @@ export default function (pi: ExtensionAPI) {
118
126
  });
119
127
 
120
128
  // Typed service published via Symbol.for() for cross-extension access.
121
- // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
129
+ // Consumers: const { getSubagentsService } = await import("@nklisch/pi-subagents");
122
130
  const service = new SubagentsServiceAdapter(manager, resolveModel, runtime, registry, settings);
123
131
  publishSubagentsService(service);
124
132
 
@@ -151,13 +159,14 @@ export default function (pi: ExtensionAPI) {
151
159
 
152
160
  pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
153
161
 
154
- // ---- get_subagent_result tool ----
162
+ // ---- parent-only lifecycle control tools ----
155
163
 
164
+ pi.registerTool(new ResumeTool(manager).toToolDefinition());
165
+ pi.registerTool(new StopTool(manager).toToolDefinition());
166
+ pi.registerTool(new ListTool(manager).toToolDefinition());
156
167
  pi.registerTool(new GetResultTool(manager, registry).toToolDefinition());
157
-
158
- // ---- steer_subagent tool ----
159
-
160
- pi.registerTool(new SteerTool(manager, pi.events).toToolDefinition());
168
+ pi.registerTool(new SteerTool({ steer: (id, message) => manager.steer(id, message) }, pi.events).toToolDefinition());
169
+ pi.registerTool(new QuerySessionTool(manager, (path) => readFileSync(path, "utf8")).toToolDefinition());
161
170
 
162
171
  // ---- /subagents:settings command ----
163
172
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generic layered settings loader for `@gotgenes/pi-*` extensions.
2
+ * Generic layered settings loader for `@nklisch/pi-*` extensions.
3
3
  *
4
4
  * Extensions that store configuration in JSON files under a global agent
5
5
  * directory and a per-project `.pi/` folder share the same three-step idiom:
@@ -15,7 +15,7 @@
15
15
  * ## Usage
16
16
  *
17
17
  * ```typescript
18
- * import { loadLayeredSettings, type LayeredSettingsSource } from "@gotgenes/pi-subagents/settings";
18
+ * import { loadLayeredSettings, type LayeredSettingsSource } from "@nklisch/pi-subagents/settings";
19
19
  *
20
20
  * interface MyConfig { enabled?: boolean; limit?: number }
21
21
  *
@@ -1,69 +1,35 @@
1
- /**
2
- * child-lifecycle.ts — Child-execution lifecycle event contract and publisher.
3
- *
4
- * The core publishes its child-execution lifecycle as ordered events on the Pi
5
- * event bus; reactive consumers (permissions, telemetry, UI) subscribe rather
6
- * than the core reaching out to them (ADR 0002). This module owns the channel
7
- * names, payload shapes, and the publisher that emits them.
8
- *
9
- * The publisher takes an injected `emit` callback so this module stays free of
10
- * Pi SDK imports — `index.ts` wires it to `pi.events.emit`.
11
- */
1
+ /** Ordered child-session lifecycle hooks used by cooperating extensions. */
12
2
 
13
3
  import { debugLog } from "#src/debug";
4
+ import type { SubagentLifecycleOutcome } from "#src/lifecycle/lifecycle-interceptor";
14
5
 
15
- /** Emitted at the start of a child run, before the session is created. */
16
6
  export const SUBAGENT_CHILD_SPAWNING = "subagents:child:spawning";
17
-
18
- /**
19
- * Emitted after the child session is created, immediately before
20
- * `bindExtensions()`. Carries the child session id consumers need to register
21
- * the session in `SubagentSessionRegistry`. Subscribers must register
22
- * synchronously so the entry lands before binding proceeds (see ADR 0002 /
23
- * the event-bus synchronous-dispatch guarantee).
24
- */
25
7
  export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
26
-
27
- /** Emitted after the child's prompt resolves (normal, steered, or aborted). */
28
8
  export const SUBAGENT_CHILD_COMPLETED = "subagents:child:completed";
29
-
30
- /** Emitted in the run's `finally` — always fires, on success and error. */
31
9
  export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
32
10
 
33
- /** Payload for `subagents:child:spawning`. */
34
11
  export interface ChildSpawningEvent {
35
12
  agentName: string;
36
13
  parentSessionId?: string;
37
14
  }
38
15
 
39
- /** Payload for `subagents:child:session-created`. */
40
16
  export interface ChildSessionCreatedEvent {
41
- /** Child session id — the registry key. Unique per child; concurrent
42
- * siblings of the same parent occupy distinct keys. */
43
17
  sessionId: string;
44
18
  parentSessionId?: string;
45
19
  }
46
20
 
47
- /** Payload for `subagents:child:completed`. */
48
21
  export interface ChildCompletedEvent {
49
22
  sessionDir: string;
50
23
  agentName: string;
51
- /** True if the run was hard-aborted (max turns + grace exceeded). */
52
- aborted: boolean;
53
- /** True if the run was steered to wrap up (soft turn limit) but finished. */
54
- steered: boolean;
24
+ terminalReason: SubagentLifecycleOutcome | "lifecycle_abort";
55
25
  }
56
26
 
57
- /** Payload for `subagents:child:disposed`. */
58
27
  export interface ChildDisposedEvent {
59
- /** Child session id — the registry key. Must match `session-created`. */
60
28
  sessionId: string;
61
29
  }
62
30
 
63
- /** Narrow emit seam — injected, never imports the Pi SDK. */
64
31
  export type LifecycleEmit = (channel: string, data: unknown) => void;
65
32
 
66
- /** Publishes the child-execution lifecycle on the event bus. */
67
33
  export interface ChildLifecyclePublisher {
68
34
  spawning(event: ChildSpawningEvent): void;
69
35
  sessionCreated(event: ChildSessionCreatedEvent): void;
@@ -71,38 +37,19 @@ export interface ChildLifecyclePublisher {
71
37
  disposed(event: ChildDisposedEvent): void;
72
38
  }
73
39
 
74
- /** Build a publisher backed by an injected `emit` callback. */
75
- export function createChildLifecyclePublisher(
76
- emit: LifecycleEmit,
77
- ): ChildLifecyclePublisher {
40
+ export function createChildLifecyclePublisher(emit: LifecycleEmit): ChildLifecyclePublisher {
78
41
  return {
79
- spawning(event) {
80
- publish(emit, "child lifecycle spawning", SUBAGENT_CHILD_SPAWNING, event);
81
- },
82
- sessionCreated(event) {
83
- publish(emit, "child lifecycle session-created", SUBAGENT_CHILD_SESSION_CREATED, event);
84
- },
85
- completed(event) {
86
- publish(emit, "child lifecycle completed", SUBAGENT_CHILD_COMPLETED, event);
87
- },
88
- disposed(event) {
89
- publish(emit, "child lifecycle disposed", SUBAGENT_CHILD_DISPOSED, event);
90
- },
42
+ spawning: (event) => publish(emit, "child lifecycle spawning", SUBAGENT_CHILD_SPAWNING, event),
43
+ sessionCreated: (event) => publish(emit, "child lifecycle session-created", SUBAGENT_CHILD_SESSION_CREATED, event),
44
+ completed: (event) => publish(emit, "child lifecycle completed", SUBAGENT_CHILD_COMPLETED, event),
45
+ disposed: (event) => publish(emit, "child lifecycle disposed", SUBAGENT_CHILD_DISPOSED, event),
91
46
  };
92
47
  }
93
48
 
94
- /** EventEmitter listeners are outside the extension runner; isolate their throws. */
95
- function publish(
96
- emit: LifecycleEmit,
97
- channelContext: string,
98
- channel: string,
99
- data: unknown,
100
- ): void {
49
+ function publish(emit: LifecycleEmit, context: string, channel: string, data: unknown): void {
101
50
  try {
102
- // Pi's event bus is synchronous for child lifecycle hooks by design.
103
- // Keep that ordering while preventing a subscriber from escaping into Pi.
104
51
  emit(channel, data);
105
52
  } catch (error) {
106
- debugLog(channelContext, error);
53
+ debugLog(context, error);
107
54
  }
108
55
  }
@@ -1,84 +1,130 @@
1
1
  /**
2
- * concurrency-limiter.ts — FIFO admission gate for background work.
2
+ * FIFO admission for every child run.
3
3
  *
4
- * Schedules run closures (thunks) against a dynamic limit, running them in
5
- * scheduling order as slots free. The limiter knows nothing about agents, IDs,
6
- * or the manager it owns only the active count and the pending queue.
7
- *
8
- * Every scheduled promise settles: it follows the task's settlement when the
9
- * task runs, or resolves early if clear() drops it before it starts.
4
+ * The limiter deliberately knows nothing about subagents. A queued handle can
5
+ * be cancelled by its owner, which removes that exact entry rather than
6
+ * leaving a tombstone for a later capacity change.
10
7
  */
11
8
 
12
9
  import { debugLog } from "#src/debug";
13
10
 
11
+ export interface AdmissionHandle {
12
+ readonly promise: Promise<void>;
13
+ readonly admitted: boolean;
14
+ cancel(): void;
15
+ }
16
+
17
+ interface PendingEntry {
18
+ readonly task: () => Promise<void>;
19
+ readonly resolve: () => void;
20
+ readonly reject: (error: unknown) => void;
21
+ handle: AdmissionHandleImpl;
22
+ }
23
+
24
+ class AdmissionHandleImpl implements AdmissionHandle {
25
+ admitted = false;
26
+ settled = false;
27
+ private cancelled = false;
28
+
29
+ constructor(
30
+ readonly promise: Promise<void>,
31
+ private readonly cancelPending: () => void,
32
+ ) {}
33
+
34
+ cancel(): void {
35
+ if (this.cancelled || this.admitted || this.settled) return;
36
+ this.cancelled = true;
37
+ this.cancelPending();
38
+ }
39
+
40
+ markAdmitted(): void {
41
+ this.admitted = true;
42
+ }
43
+
44
+ markSettled(): void {
45
+ this.settled = true;
46
+ }
47
+
48
+ get isCancelled(): boolean {
49
+ return this.cancelled;
50
+ }
51
+ }
52
+
14
53
  export class ConcurrencyLimiter {
15
- private active = 0;
16
- private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
17
-
18
- constructor(private readonly getLimit: () => number) {}
19
-
20
- /** Whether a newly scheduled task will wait behind work already admitted. */
21
- isSaturated(): boolean {
22
- try {
23
- return this.active >= this.getLimit();
24
- } catch (error) {
25
- debugLog("concurrency limiter limit", error);
26
- return false;
27
- }
28
- }
29
-
30
- /**
31
- * Schedule a task to run FIFO once a slot is free.
32
- * Returns a promise that settles with the task, or resolves early if the
33
- * task is dropped by clear() before it starts.
34
- */
35
- schedule(task: () => Promise<void>): Promise<void> {
36
- const { promise, resolve, reject } = Promise.withResolvers<void>(); // eslint-disable-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void> is valid; rule does not allow void in generic fn call type args
37
- this.pending.push({
38
- start: () => {
39
- this.active++;
40
- let taskPromise: Promise<void>;
41
- try {
42
- taskPromise = Promise.resolve(task());
43
- } catch (error) {
44
- taskPromise = Promise.reject(error);
45
- }
46
- taskPromise
47
- .then(resolve, reject)
48
- .finally(() => {
49
- this.active--;
50
- try {
51
- this.recheck();
52
- } catch (error) {
53
- // A failing dynamic limit must not strand the slot or
54
- // reject the detached cleanup promise.
55
- debugLog("concurrency limiter recheck", error);
56
- }
57
- })
58
- .catch((error: unknown) => debugLog("concurrency limiter cleanup", error));
59
- },
60
- settle: resolve,
61
- });
62
- this.recheck();
63
- return promise;
64
- }
65
-
66
- /** Start pending tasks until the limit is reached. Call when the limit may have grown. */
67
- recheck(): void {
68
- try {
69
- while (this.active < this.getLimit()) {
70
- const next = this.pending.shift();
71
- if (!next) break;
72
- next.start();
73
- }
74
- } catch (error) {
75
- debugLog("concurrency limiter recheck", error);
76
- }
77
- }
78
-
79
- /** Drop all pending tasks, resolving their promises without running them. */
80
- clear(): void {
81
- const dropped = this.pending.splice(0);
82
- for (const task of dropped) task.settle();
83
- }
54
+ private active = 0;
55
+ private readonly pending: PendingEntry[] = [];
56
+
57
+ constructor(private readonly getLimit: () => number) {}
58
+
59
+ isSaturated(): boolean {
60
+ try {
61
+ return this.active >= this.getLimit();
62
+ } catch (error) {
63
+ debugLog("concurrency limiter limit", error);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ get activeCount(): number {
69
+ return this.active;
70
+ }
71
+
72
+ get queuedCount(): number {
73
+ return this.pending.length;
74
+ }
75
+
76
+ schedule(task: () => Promise<void>): AdmissionHandle {
77
+ let entry!: PendingEntry;
78
+ let resolve!: () => void;
79
+ let reject!: (error: unknown) => void;
80
+ const promise = new Promise<void>((res, rej) => {
81
+ resolve = res;
82
+ reject = rej;
83
+ });
84
+ const handle = new AdmissionHandleImpl(promise, () => {
85
+ const index = this.pending.indexOf(entry);
86
+ if (index === -1) return;
87
+ this.pending.splice(index, 1);
88
+ handle.markSettled();
89
+ resolve();
90
+ });
91
+ entry = { task, resolve, reject, handle };
92
+ this.pending.push(entry);
93
+ this.recheck();
94
+ return handle;
95
+ }
96
+
97
+ recheck(): void {
98
+ try {
99
+ while (this.active < this.getLimit()) {
100
+ const next = this.pending.shift();
101
+ if (!next) return;
102
+ if (next.handle.isCancelled) continue;
103
+ next.handle.markAdmitted();
104
+ this.active++;
105
+ let taskPromise: Promise<void>;
106
+ try {
107
+ taskPromise = Promise.resolve(next.task());
108
+ } catch (error) {
109
+ taskPromise = Promise.reject(error);
110
+ }
111
+ taskPromise.then(next.resolve, next.reject).finally(() => {
112
+ next.handle.markSettled();
113
+ this.active--;
114
+ try {
115
+ this.recheck();
116
+ } catch (error) {
117
+ debugLog("concurrency limiter recheck", error);
118
+ }
119
+ }).catch((error: unknown) => {
120
+ // The task promise was already forwarded to the caller. This catch
121
+ // only contains a rejected `finally` continuation.
122
+ debugLog("concurrency limiter cleanup", error);
123
+ });
124
+ }
125
+ } catch (error) {
126
+ debugLog("concurrency limiter recheck", error);
127
+ }
128
+ }
129
+
84
130
  }
@@ -24,9 +24,7 @@ import { SubagentSession } from "#src/lifecycle/subagent-session";
24
24
  import type { EnvInfo } from "#src/session/env";
25
25
  import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
26
26
  import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
27
-
28
- /** Names of tools registered by this extension that subagents must NOT inherit. */
29
- const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
27
+ import { PARENT_ONLY_TOOL_NAMES, PARENT_ONLY_TOOL_SET } from "#src/tools/parent-tool-registry";
30
28
 
31
29
  /**
32
30
  * Apply the recursion guard: remove this extension's dispatch tools from the
@@ -36,7 +34,7 @@ const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"
36
34
  function applyRecursionGuard(session: AgentSession): void {
37
35
  const filtered = session
38
36
  .getActiveToolNames()
39
- .filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
37
+ .filter((t) => !PARENT_ONLY_TOOL_SET.has(t));
40
38
  session.setActiveToolsByName(filtered);
41
39
  }
42
40
 
@@ -212,7 +210,7 @@ export async function createSubagentSession(
212
210
  modelRegistry: snapshot.modelRegistry,
213
211
  modelRuntime: snapshot.modelRuntime,
214
212
  model: cfg.model,
215
- excludeTools: [...new Set([...cfg.excludedBuiltinToolNames, ...EXCLUDED_TOOL_NAMES])],
213
+ excludeTools: [...new Set([...cfg.excludedBuiltinToolNames, ...PARENT_ONLY_TOOL_NAMES])],
216
214
  resourceLoader: loader,
217
215
  thinkingLevel: cfg.thinkingLevel,
218
216
  });
@@ -8,21 +8,22 @@
8
8
  */
9
9
 
10
10
  import { debugLog } from "#src/debug";
11
+ import type { SubagentMode } from "#src/types";
11
12
 
12
13
  /** The fixed bound prevents a provider from turning completion into an unbounded loop. */
13
14
  export const MAX_LIFECYCLE_CONTINUATION_ROUNDS = 3;
14
15
 
15
16
  export type SubagentExecutionPhase = "initial" | "resume";
16
17
  export type SubagentExecutionOrigin = "tool" | "service";
17
- export type SubagentExecutionMode = "foreground" | "background";
18
+ export type SubagentExecutionMode = SubagentMode;
18
19
  export type SubagentExecutionAdmission = "immediate" | "queued";
19
- export type SubagentLifecycleOutcome = "completed" | "steered" | "aborted";
20
+ export type SubagentLifecycleOutcome = "completed" | "turn_limit_graceful" | "turn_limit_hard";
20
21
 
21
22
  /** Immutable identifiers for one initial or resumed execution attempt. */
22
23
  export interface SubagentLifecycleIdentity {
23
24
  readonly agentId: string;
24
25
  readonly sessionId: string;
25
- readonly runId: string;
26
+ readonly runId: number;
26
27
  readonly agentType: string;
27
28
  readonly parentSessionId?: string;
28
29
  }
@@ -1,28 +1,10 @@
1
- /**
2
- * run-listeners.ts — Per-run observer-unsubscribe and signal-detach handles.
3
- *
4
- * Owns the two teardown handles that a Subagent wires at run start (signal
5
- * listener) and after session creation (record-observer unsub), releasing
6
- * both atomically when the run ends or the agent is resumed.
7
- */
1
+ /** Per-run observer-unsubscribe handle. */
8
2
 
9
3
  import { runSafely } from "#src/debug";
10
4
 
11
- /** Owns the per-run observer-unsubscribe and signal-detach handles. */
5
+ /** Owns the per-run observer-unsubscribe handle. */
12
6
  export class RunListeners {
13
7
  private unsub?: () => void;
14
- private detach?: () => void;
15
-
16
- /**
17
- * Wire a parent AbortSignal so it triggers onAbort when fired.
18
- * No-op when signal is undefined.
19
- */
20
- wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
21
- if (!signal) return;
22
- const listener = () => onAbort();
23
- signal.addEventListener("abort", listener, { once: true });
24
- this.detach = () => signal.removeEventListener("abort", listener);
25
- }
26
8
 
27
9
  /** Store the record-observer unsubscribe handle. */
28
10
  attachObserver(unsub: () => void): void {
@@ -33,9 +15,6 @@ export class RunListeners {
33
15
  release(): void {
34
16
  const unsub = this.unsub;
35
17
  this.unsub = undefined;
36
- const detach = this.detach;
37
- this.detach = undefined;
38
18
  runSafely("subagent observer unsubscribe", () => unsub?.());
39
- runSafely("subagent abort listener detach", () => detach?.());
40
19
  }
41
20
  }