@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.2

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 (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +32 -7
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
@@ -0,0 +1,232 @@
1
+ import * as path from "node:path";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import { isInsideTmux, wrapTmuxPassthrough } from "@oh-my-pi/pi-tui/terminal-capabilities";
4
+ import { VERSION } from "@oh-my-pi/pi-utils/dirs";
5
+ import type { ExtensionContext, ExtensionFactory } from "../extensibility/extensions/types";
6
+ import { isSilentAbort, isUserInterruptAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
7
+
8
+ const WARP_CLI_AGENT_PROTOCOL_VERSION = 1;
9
+ const WARP_CLI_AGENT_SENTINEL = "warp://cli-agent";
10
+ const WARP_ATTENTION_EVENTS: Record<string, true> = {
11
+ stop: true,
12
+ stop_failure: true,
13
+ permission_request: true,
14
+ question_asked: true,
15
+ };
16
+
17
+ /** True when Warp has negotiated the structured CLI-agent OSC protocol. */
18
+ export function isWarpCliAgentProtocolActive(): boolean {
19
+ return Number(process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) >= WARP_CLI_AGENT_PROTOCOL_VERSION;
20
+ }
21
+
22
+ export type WarpEventValue =
23
+ | string
24
+ | number
25
+ | boolean
26
+ | null
27
+ | readonly WarpEventValue[]
28
+ | { readonly [key: string]: WarpEventValue | undefined };
29
+
30
+ /** Fields added to the Warp CLI-agent event envelope by the event bridge. */
31
+ export type WarpEvent = Readonly<Record<string, WarpEventValue | undefined>>;
32
+
33
+ export interface WarpEventEmitterOptions {
34
+ sessionId: string;
35
+ getCwd?: () => string;
36
+ }
37
+
38
+ export interface WarpEventEmitter {
39
+ emit(event: WarpEvent): void;
40
+ }
41
+
42
+ /**
43
+ * Creates the Warp event transport for a top-level interactive TUI session.
44
+ * The caller MUST enforce that install-site invariant; the sole production
45
+ * caller is gated by `isInteractive`, so ACP, RPC, print, headless, and
46
+ * subagent sessions never construct an emitter.
47
+ */
48
+ export function createWarpEventEmitter(options: WarpEventEmitterOptions): WarpEventEmitter | undefined {
49
+ if (!isWarpCliAgentProtocolActive()) {
50
+ return undefined;
51
+ }
52
+
53
+ return {
54
+ emit(event): void {
55
+ const cwd = options.getCwd?.() ?? process.cwd();
56
+ const body = {
57
+ ...event,
58
+ v: WARP_CLI_AGENT_PROTOCOL_VERSION,
59
+ // Warp resolves this via CLIAgent.command_prefix(); OhMyPi is "omp".
60
+ agent: "omp",
61
+ session_id: options.sessionId,
62
+ cwd,
63
+ project: path.basename(cwd),
64
+ plugin_version: VERSION,
65
+ };
66
+ const osc = `\x1b]777;notify;${WARP_CLI_AGENT_SENTINEL};${JSON.stringify(body)}\x07`;
67
+ if (!isInsideTmux()) {
68
+ process.stdout.write(osc);
69
+ return;
70
+ }
71
+ // DCS-wrap every OSC so Warp can parse it under allow-passthrough.
72
+ // Outer BEL after DCS is only for attention-worthy events so tmux
73
+ // monitor-bell flags the pane; the OSC's own trailing \x07 is its
74
+ // terminator and does not drive the outer bell after wrapping.
75
+ const wrapped = wrapTmuxPassthrough(osc);
76
+ const eventName = event.event;
77
+ const ring = typeof eventName === "string" && Object.hasOwn(WARP_ATTENTION_EVENTS, eventName);
78
+ process.stdout.write(ring ? `${wrapped}\x07` : wrapped);
79
+ },
80
+ };
81
+ }
82
+
83
+ type LastAssistantStop = {
84
+ response: string;
85
+ event: "stop" | "stop_failure";
86
+ error_type?: "error" | "aborted";
87
+ };
88
+
89
+ function lastAssistantStop(messages: readonly AgentMessage[]): LastAssistantStop {
90
+ for (let index = messages.length - 1; index >= 0; index--) {
91
+ const message = messages[index];
92
+ if (message.role !== "assistant") continue;
93
+
94
+ const text = message.content
95
+ .filter(content => content.type === "text")
96
+ .map(content => content.text)
97
+ .join("");
98
+
99
+ let response = "";
100
+ if (text.length > 0) {
101
+ response = text;
102
+ } else {
103
+ const errorMessage = message.errorMessage;
104
+ if (typeof errorMessage === "string" && errorMessage.length > 0 && !isSilentAbort(message)) {
105
+ if (message.stopReason === "error") {
106
+ response = errorMessage;
107
+ } else if (message.stopReason === "aborted" && !isUserInterruptAbort(message)) {
108
+ response = errorMessage;
109
+ }
110
+ }
111
+ }
112
+
113
+ if (message.stopReason === "error") {
114
+ return { response, event: "stop_failure", error_type: "error" };
115
+ }
116
+ if (
117
+ message.stopReason === "aborted" &&
118
+ !isSilentAbort(message) &&
119
+ !isUserInterruptAbort(message) &&
120
+ typeof message.errorMessage === "string" &&
121
+ message.errorMessage.length > 0
122
+ ) {
123
+ return { response, event: "stop_failure", error_type: "aborted" };
124
+ }
125
+ return { response, event: "stop" };
126
+ }
127
+ return { response: "", event: "stop" };
128
+ }
129
+
130
+ function truncateEventText(text: string): string {
131
+ let end = 0;
132
+ let count = 0;
133
+ for (const codePoint of text) {
134
+ if (count === 200) break;
135
+ end += codePoint.length;
136
+ count++;
137
+ }
138
+ return text.slice(0, end);
139
+ }
140
+
141
+ function messageText(content: unknown): string {
142
+ if (typeof content === "string") {
143
+ return content;
144
+ }
145
+ if (!Array.isArray(content)) {
146
+ return "";
147
+ }
148
+ return content
149
+ .filter(
150
+ (block): block is { type: "text"; text: string } =>
151
+ !!block &&
152
+ typeof block === "object" &&
153
+ "type" in block &&
154
+ block.type === "text" &&
155
+ "text" in block &&
156
+ typeof block.text === "string",
157
+ )
158
+ .map(block => block.text)
159
+ .join("");
160
+ }
161
+
162
+ /** Internal event bridge installed only by the top-level interactive TUI runner. */
163
+ export function createWarpEventBridgeExtension(): ExtensionFactory {
164
+ return api => {
165
+ let emitter: WarpEventEmitter | undefined;
166
+ let activePrompt: string | undefined;
167
+ let getCwd: (() => string) | undefined;
168
+
169
+ const rebuildEmitter = (_event: unknown, ctx: ExtensionContext): void => {
170
+ activePrompt = undefined;
171
+ getCwd = () => ctx.sessionManager.getCwd();
172
+ emitter = createWarpEventEmitter({ sessionId: ctx.sessionManager.getSessionId(), getCwd });
173
+ emitter?.emit({ event: "session_start" });
174
+ };
175
+
176
+ api.on("session_start", rebuildEmitter);
177
+ api.on("session_switch", rebuildEmitter);
178
+ api.on("session_branch", rebuildEmitter);
179
+
180
+ api.on("message_start", event => {
181
+ const message = event.message as AgentMessage;
182
+ if (message.role === "user") {
183
+ if (message.synthetic || message.attribution === "agent") {
184
+ return;
185
+ }
186
+ } else if (
187
+ message.role !== "custom" ||
188
+ message.customType !== SKILL_PROMPT_MESSAGE_TYPE ||
189
+ message.attribution !== "user"
190
+ ) {
191
+ return;
192
+ }
193
+ activePrompt = truncateEventText(messageText(message.content));
194
+ emitter?.emit({ event: "prompt_submit", query: activePrompt });
195
+ });
196
+
197
+ api.on("tool_approval_requested", event => {
198
+ emitter?.emit({
199
+ event: "permission_request",
200
+ tool_name: event.toolName,
201
+ summary: `omp wants to run ${event.toolName}`,
202
+ });
203
+ });
204
+
205
+ api.on("tool_approval_resolved", () => {
206
+ emitter?.emit({ event: "permission_replied" });
207
+ });
208
+
209
+ api.on("tool_execution_start", event => {
210
+ if (event.toolName === "ask") {
211
+ emitter?.emit({ event: "question_asked", summary: "Waiting for your answer" });
212
+ }
213
+ });
214
+
215
+ api.on("tool_result", event => {
216
+ emitter?.emit({ event: "tool_complete", tool_name: event.toolName });
217
+ });
218
+
219
+ api.on("agent_end", event => {
220
+ if (event.willContinue) {
221
+ return;
222
+ }
223
+ const stop = lastAssistantStop(event.messages);
224
+ emitter?.emit({
225
+ event: stop.event,
226
+ query: activePrompt,
227
+ response: truncateEventText(stop.response),
228
+ ...(stop.error_type !== undefined ? { error_type: stop.error_type } : {}),
229
+ });
230
+ });
231
+ };
232
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
+ import type { Model } from "@oh-my-pi/pi-ai";
4
+ import type { ResolvedModelRoleValue } from "../config/model-resolver";
5
+ import { AUTO_THINKING } from "../thinking";
6
+ import { resolvePlanModelTransition } from "./model-transition";
7
+
8
+ /**
9
+ * Plan-mode model transition policy (issue #5657). The active model in plan
10
+ * mode IS the plan-role model, so reassigning that role mid-planning must move
11
+ * the session onto the new model — or defer the switch when a turn is streaming.
12
+ */
13
+ const model = (provider: string, id: string): Model => ({ provider, id }) as unknown as Model;
14
+
15
+ const resolved = (
16
+ m: Model | undefined,
17
+ thinking?: ResolvedModelRoleValue["thinkingLevel"],
18
+ ): ResolvedModelRoleValue => ({
19
+ model: m,
20
+ thinkingLevel: thinking,
21
+ explicitThinkingLevel: thinking !== undefined,
22
+ warning: undefined,
23
+ });
24
+
25
+ describe("resolvePlanModelTransition", () => {
26
+ it("switches to the resolved plan model when it differs from the active one", () => {
27
+ const plan = model("anthropic", "opus");
28
+ const transition = resolvePlanModelTransition(model("openai", "gpt"), resolved(plan), false);
29
+ expect(transition).toEqual({ kind: "apply", model: plan, thinkingLevel: undefined, deferred: false });
30
+ });
31
+
32
+ it("defers the switch while the session is streaming", () => {
33
+ const plan = model("anthropic", "opus");
34
+ const transition = resolvePlanModelTransition(model("openai", "gpt"), resolved(plan), true);
35
+ expect(transition).toEqual({ kind: "apply", model: plan, thinkingLevel: undefined, deferred: true });
36
+ });
37
+
38
+ it("carries the plan role's explicit thinking level into the switch", () => {
39
+ const plan = model("anthropic", "opus");
40
+ const transition = resolvePlanModelTransition(model("openai", "gpt"), resolved(plan, ThinkingLevel.High), false);
41
+ expect(transition).toEqual({ kind: "apply", model: plan, thinkingLevel: ThinkingLevel.High, deferred: false });
42
+ });
43
+
44
+ it("only adjusts thinking when the model is unchanged but the level is explicit", () => {
45
+ const plan = model("anthropic", "opus");
46
+ const transition = resolvePlanModelTransition(plan, resolved(model("anthropic", "opus"), AUTO_THINKING), false);
47
+ expect(transition).toEqual({ kind: "thinking", thinkingLevel: AUTO_THINKING });
48
+ });
49
+
50
+ it("is a no-op when the model matches and no explicit thinking level is set", () => {
51
+ const plan = model("anthropic", "opus");
52
+ const transition = resolvePlanModelTransition(plan, resolved(model("anthropic", "opus")), false);
53
+ expect(transition).toEqual({ kind: "none" });
54
+ });
55
+
56
+ it("is a no-op when the plan role resolves to no model", () => {
57
+ const transition = resolvePlanModelTransition(model("openai", "gpt"), resolved(undefined), false);
58
+ expect(transition).toEqual({ kind: "none" });
59
+ });
60
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Plan-mode model transition policy.
3
+ *
4
+ * Plan mode drives the active session model from the `plan` role: it switches
5
+ * to the plan model on entry and restores the pre-plan model on exit. The same
6
+ * policy also runs when the `plan` role is reassigned mid-planning, so a
7
+ * correction to the wrong plan model takes effect at the next turn boundary
8
+ * (issue #5657).
9
+ *
10
+ * This module holds only the pure decision — what transition a given
11
+ * (current model, resolved role, streaming) triple implies — so the branching
12
+ * is testable without a live session or TUI. The interactive mode performs the
13
+ * resulting side effect.
14
+ */
15
+ import type { Model } from "@oh-my-pi/pi-ai";
16
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
17
+ import type { ResolvedModelRoleValue } from "../config/model-resolver";
18
+ import type { ConfiguredThinkingLevel } from "../thinking";
19
+
20
+ /** The action implied by resolving the `plan` role against the active model. */
21
+ export type PlanModelTransition =
22
+ /** Already on the plan model with no thinking-level change to apply. */
23
+ | { kind: "none" }
24
+ /** Same model; only the plan role's explicit thinking level differs. */
25
+ | { kind: "thinking"; thinkingLevel: ConfiguredThinkingLevel }
26
+ /**
27
+ * Switch to `model`. `deferred` is set when the session is mid-stream: the
28
+ * switch must wait for the current turn to end (a live `setModelTemporary`
29
+ * resets the provider session), so the caller queues it instead.
30
+ */
31
+ | { kind: "apply"; model: Model; thinkingLevel: ConfiguredThinkingLevel | undefined; deferred: boolean };
32
+
33
+ /**
34
+ * Decide how to reconcile the active model with the resolved `plan` role.
35
+ *
36
+ * @param currentModel - The session's active model, or `undefined` before one is set.
37
+ * @param resolved - The `plan` role resolved against the available models.
38
+ * @param isStreaming - Whether the session is mid-turn (forces a deferred switch).
39
+ */
40
+ export function resolvePlanModelTransition(
41
+ currentModel: Model | undefined,
42
+ resolved: ResolvedModelRoleValue,
43
+ isStreaming: boolean,
44
+ ): PlanModelTransition {
45
+ if (!resolved.model) return { kind: "none" };
46
+ const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
47
+ if (modelsAreEqual(currentModel, resolved.model)) {
48
+ return planThinkingLevel ? { kind: "thinking", thinkingLevel: planThinkingLevel } : { kind: "none" };
49
+ }
50
+ return { kind: "apply", model: resolved.model, thinkingLevel: planThinkingLevel, deferred: isStreaming };
51
+ }
@@ -8,6 +8,12 @@
8
8
  * sessionFile), and revives it on demand through
9
9
  * {@link AgentLifecycleManager.ensureLive}. Only this manager flips
10
10
  * `parked` ↔ `idle`.
11
+ *
12
+ * Park/dispose is gated against concurrent ensureLive/hub-send:
13
+ * - A disposing session is never handed out.
14
+ * - ensureLive during an in-flight park either cancels the park (session still
15
+ * live) or waits for detach+park and then revives.
16
+ * - Concurrent ensureLive/park operations coalesce per id.
11
17
  */
12
18
 
13
19
  import { logger } from "@oh-my-pi/pi-utils";
@@ -38,6 +44,17 @@ interface AdoptedAgent {
38
44
  timer?: NodeJS.Timeout;
39
45
  }
40
46
 
47
+ interface ParkInFlight {
48
+ /** Resolves when the park attempt finishes (success, cancel, or dispose error). */
49
+ promise: Promise<void>;
50
+ /** Cancel before the session is detached. Returns true if cancel took effect. */
51
+ cancel: () => boolean;
52
+ /** True once cancel() succeeded (ensureLive kept the live session). */
53
+ cancelled: boolean;
54
+ /** True once the live session has been detached and status is parked. */
55
+ detached: boolean;
56
+ }
57
+
41
58
  export class AgentLifecycleManager {
42
59
  static #global: AgentLifecycleManager | undefined;
43
60
 
@@ -59,7 +76,7 @@ export class AgentLifecycleManager {
59
76
  }
60
77
  current.#adopted.clear();
61
78
  current.#revivals.clear();
62
- current.#parking.clear();
79
+ current.#parks.clear();
63
80
  current.#persistedReviverFactory = undefined;
64
81
  }
65
82
  AgentLifecycleManager.#global = undefined;
@@ -67,8 +84,11 @@ export class AgentLifecycleManager {
67
84
 
68
85
  readonly #registry: AgentRegistry;
69
86
  readonly #adopted = new Map<string, AdoptedAgent>();
70
- /** Ids whose session is being disposed by {@link park} right now. */
71
- readonly #parking = new Set<string>();
87
+ /**
88
+ * In-flight park attempts. A park is cancelable until the live session is
89
+ * detached; after detach, ensureLive waits for the park and revives.
90
+ */
91
+ readonly #parks = new Map<string, ParkInFlight>();
72
92
  /** In-flight revives, so concurrent {@link ensureLive} calls coalesce. */
73
93
  readonly #revivals = new Map<string, Promise<AgentSession>>();
74
94
  #unsubscribe: (() => void) | undefined;
@@ -114,44 +134,129 @@ export class AgentLifecycleManager {
114
134
  return this.#adopted.has(id);
115
135
  }
116
136
 
117
- /** True while {@link park} is disposing this agent's session (lets dispose hooks distinguish park from teardown). */
137
+ /**
138
+ * True when this manager owns `registry` — i.e. its adopt/park/revive state
139
+ * describes that registry's refs. Lets a caller holding a specific registry
140
+ * (e.g. a custom-registry {@link IrcBus} that fell back to the global
141
+ * manager) skip lifecycle gating that would consult unrelated park state.
142
+ */
143
+ manages(registry: AgentRegistry): boolean {
144
+ return this.#registry === registry;
145
+ }
146
+
147
+ /**
148
+ * True while {@link park} is disposing this agent's session (lets dispose
149
+ * hooks distinguish park from teardown). False once the park is cancelled
150
+ * by ensureLive or after detach+dispose completes.
151
+ */
118
152
  isParking(id: string): boolean {
119
- return this.#parking.has(id);
153
+ const park = this.#parks.get(id);
154
+ return Boolean(park && !park.cancelled);
120
155
  }
121
156
 
122
157
  /**
123
158
  * Dispose the live session, detach it from the registry, and mark the
124
159
  * agent `parked`. No-op unless the id is adopted and live.
160
+ *
161
+ * The session is detached (and status flipped to `parked`) *before*
162
+ * `session.dispose()` so concurrent {@link ensureLive}/hub-send never
163
+ * observe or inject into a disposing session. A concurrent ensureLive that
164
+ * arrives before detach cancels the park and keeps the live session.
125
165
  */
126
166
  async park(id: string): Promise<void> {
167
+ const existing = this.#parks.get(id);
168
+ if (existing) return existing.promise;
169
+
127
170
  const adopted = this.#adopted.get(id);
128
171
  if (!adopted) return;
129
172
  const ref = this.#registry.get(id);
130
- if (!ref?.session) return;
173
+ const session = ref?.session;
174
+ if (!session) return;
175
+
131
176
  if (adopted.timer) {
132
177
  clearTimeout(adopted.timer);
133
178
  adopted.timer = undefined;
134
179
  }
135
- this.#parking.add(id);
136
- try {
180
+
181
+ let cancelled = false;
182
+ const park: ParkInFlight = {
183
+ promise: undefined as unknown as Promise<void>,
184
+ cancel: () => {
185
+ // Cancel only before detach — once detached the old session is already
186
+ // leaving the registry and must finish disposing.
187
+ if (park.detached || cancelled) return cancelled;
188
+ cancelled = true;
189
+ park.cancelled = true;
190
+ return true;
191
+ },
192
+ cancelled: false,
193
+ detached: false,
194
+ };
195
+
196
+ park.promise = (async () => {
137
197
  try {
138
- await ref.session.dispose();
139
- } catch (error) {
140
- logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
198
+ // Yield so a same-tick ensureLive/hub-send can cancel before we
199
+ // commit to dispose. Deterministic with Promise microtasks; no timers.
200
+ await Promise.resolve();
201
+ if (cancelled) return;
202
+
203
+ // Re-check liveness: release/unregister may have raced us.
204
+ const live = this.#registry.get(id);
205
+ if (!live?.session || live.session !== session) return;
206
+ if (!this.#adopted.has(id)) return;
207
+
208
+ // Commit: detach + parked *before* dispose so callers never see a
209
+ // dying session via ref.session / idle status.
210
+ park.detached = true;
211
+ this.#registry.detachSession(id);
212
+ this.#registry.setStatus(id, "parked");
213
+
214
+ try {
215
+ await session.dispose();
216
+ } catch (error) {
217
+ logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
218
+ }
219
+ } finally {
220
+ // Only clear if we are still the in-flight entry (a later park would
221
+ // have replaced us only after we resolved).
222
+ if (this.#parks.get(id) === park) this.#parks.delete(id);
141
223
  }
142
- this.#registry.detachSession(id);
143
- this.#registry.setStatus(id, "parked");
144
- } finally {
145
- this.#parking.delete(id);
146
- }
224
+ })();
225
+
226
+ this.#parks.set(id, park);
227
+ return park.promise;
147
228
  }
148
229
 
149
230
  /**
150
231
  * Return the live session, reviving from the sessionFile if parked.
151
232
  * Throws a plain Error if the id is unknown or parked without a reviver.
152
233
  * Concurrent calls share one in-flight revive.
234
+ *
235
+ * Never returns a session that is mid-dispose: an in-flight park is either
236
+ * cancelled (session still live) or awaited to completion before revive.
153
237
  */
154
238
  async ensureLive(id: string): Promise<AgentSession> {
239
+ const park = this.#parks.get(id);
240
+ if (park) {
241
+ const ref = this.#registry.get(id);
242
+ // Cancel if the live session is still attached — keep it instead of
243
+ // thrashing dispose + revive.
244
+ if (ref?.session && !park.detached && park.cancel()) {
245
+ await park.promise;
246
+ const kept = this.#registry.get(id)?.session;
247
+ if (kept) {
248
+ // Park cleared the idle timer; re-arm so TTL park still works.
249
+ const adopted = this.#adopted.get(id);
250
+ if (adopted && ref.status === "idle") this.#armTimer(id, adopted);
251
+ return kept;
252
+ }
253
+ } else {
254
+ // Already committed to detach (or no live session): wait for park,
255
+ // then fall through to the revive path.
256
+ await park.promise;
257
+ }
258
+ }
259
+
155
260
  const ref = this.#registry.get(id);
156
261
  if (!ref) {
157
262
  throw new Error(
@@ -208,6 +313,14 @@ export class AgentLifecycleManager {
208
313
  const adopted = this.#adopted.get(id);
209
314
  clearTimeout(adopted?.timer);
210
315
  this.#adopted.delete(id);
316
+
317
+ const park = this.#parks.get(id);
318
+ if (park) {
319
+ // Prefer cancel when the session is still live so release owns dispose.
320
+ if (!park.detached) park.cancel();
321
+ await park.promise;
322
+ }
323
+
211
324
  const ref = this.#registry.get(id);
212
325
  if (ref?.session) {
213
326
  try {
@@ -223,10 +336,10 @@ export class AgentLifecycleManager {
223
336
  async dispose(): Promise<void> {
224
337
  this.#unsubscribe?.();
225
338
  this.#unsubscribe = undefined;
226
- const ids = [...this.#adopted.keys()];
339
+ const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])];
227
340
  await Promise.all(ids.map(id => this.release(id)));
228
341
  this.#revivals.clear();
229
- this.#parking.clear();
342
+ this.#parks.clear();
230
343
  this.#persistedReviverFactory = undefined;
231
344
  }
232
345
 
@@ -264,6 +377,8 @@ export class AgentLifecycleManager {
264
377
  adopted.timer = undefined;
265
378
  }
266
379
  } else if (event.ref.status === "idle") {
380
+ // Don't re-arm while a park is in flight — the park owns the transition.
381
+ if (this.#parks.has(event.ref.id)) return;
267
382
  this.#armTimer(event.ref.id, adopted);
268
383
  }
269
384
  }