@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.
- package/CHANGELOG.md +92 -20
- package/dist/cli.js +3485 -3448
- package/dist/types/advisor/config.d.ts +14 -6
- package/dist/types/advisor/runtime.d.ts +20 -11
- package/dist/types/autolearn/controller.d.ts +1 -0
- package/dist/types/config/model-discovery.d.ts +17 -0
- package/dist/types/config/model-resolver.d.ts +6 -2
- package/dist/types/config/settings-schema.d.ts +32 -7
- package/dist/types/config/settings.d.ts +50 -0
- package/dist/types/cursor.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +5 -2
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +18 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/extensibility/utils.d.ts +2 -2
- package/dist/types/mcp/transports/stdio.d.ts +13 -1
- package/dist/types/modes/components/advisor-config.d.ts +8 -1
- package/dist/types/modes/components/hook-editor.d.ts +7 -0
- package/dist/types/modes/components/model-hub.d.ts +5 -2
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +8 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/print-mode.d.ts +1 -1
- package/dist/types/modes/warp-events.d.ts +24 -0
- package/dist/types/modes/warp-events.test.d.ts +1 -0
- package/dist/types/plan-mode/model-transition.d.ts +47 -0
- package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
- package/dist/types/registry/agent-lifecycle.d.ts +26 -1
- package/dist/types/sdk.d.ts +13 -2
- package/dist/types/session/agent-session.d.ts +34 -10
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-manager.d.ts +14 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
- package/dist/types/task/label.d.ts +1 -1
- package/dist/types/telemetry-export.d.ts +34 -9
- package/dist/types/tools/approval.d.ts +8 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/essential-tools.d.ts +29 -0
- package/dist/types/tools/image-gen.d.ts +2 -1
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/xdev.d.ts +11 -2
- package/dist/types/utils/title-generator.d.ts +2 -1
- package/dist/types/web/search/providers/kimi.d.ts +4 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +21 -16
- package/src/advisor/__tests__/advisor.test.ts +1304 -42
- package/src/advisor/__tests__/config.test.ts +58 -2
- package/src/advisor/config.ts +76 -24
- package/src/advisor/runtime.ts +445 -92
- package/src/autolearn/controller.ts +23 -28
- package/src/cli.ts +5 -1
- package/src/config/model-discovery.ts +81 -21
- package/src/config/model-registry.ts +25 -6
- package/src/config/model-resolver.ts +14 -7
- package/src/config/settings-schema.ts +42 -6
- package/src/config/settings.ts +405 -25
- package/src/cursor.ts +20 -3
- package/src/debug/report-bundle.ts +40 -4
- package/src/discovery/helpers.ts +28 -5
- package/src/exec/bash-executor.ts +14 -5
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/extensibility/extensions/loader.ts +3 -3
- package/src/extensibility/extensions/managed-timers.ts +83 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/types.ts +18 -0
- package/src/extensibility/extensions/wrapper.ts +2 -1
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
- package/src/extensibility/plugins/manager.ts +2 -2
- package/src/extensibility/shared-events.ts +6 -0
- package/src/extensibility/utils.ts +91 -25
- package/src/irc/bus.ts +22 -3
- package/src/launch/broker.ts +3 -2
- package/src/launch/client.ts +2 -2
- package/src/launch/presence.ts +2 -2
- package/src/lsp/client.ts +1 -1
- package/src/main.ts +11 -8
- package/src/mcp/manager.ts +9 -3
- package/src/mcp/transports/stdio.ts +103 -23
- package/src/modes/components/advisor-config.ts +65 -3
- package/src/modes/components/ask-dialog.ts +1 -1
- package/src/modes/components/hook-editor.ts +18 -3
- package/src/modes/components/model-hub.ts +138 -42
- package/src/modes/components/session-selector.ts +4 -0
- package/src/modes/components/status-line/component.test.ts +1 -0
- package/src/modes/components/status-line/segments.ts +21 -6
- package/src/modes/controllers/command-controller.ts +167 -47
- package/src/modes/controllers/event-controller.ts +5 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -22
- package/src/modes/controllers/input-controller.ts +12 -12
- package/src/modes/controllers/selector-controller.ts +191 -31
- package/src/modes/interactive-mode.ts +139 -54
- package/src/modes/print-mode.ts +3 -3
- package/src/modes/rpc/host-tools.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +19 -4
- package/src/modes/warp-events.test.ts +794 -0
- package/src/modes/warp-events.ts +232 -0
- package/src/plan-mode/model-transition.test.ts +60 -0
- package/src/plan-mode/model-transition.ts +51 -0
- package/src/registry/agent-lifecycle.ts +133 -18
- package/src/sdk.ts +221 -42
- package/src/session/agent-session.ts +1285 -348
- package/src/session/session-history-format.ts +20 -5
- package/src/session/session-manager.ts +48 -0
- package/src/slash-commands/builtin-registry.ts +7 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
- package/src/task/executor.ts +1 -1
- package/src/task/label.ts +2 -0
- package/src/telemetry-export.ts +453 -97
- package/src/tools/approval.ts +11 -0
- package/src/tools/bash.ts +71 -38
- package/src/tools/essential-tools.ts +45 -0
- package/src/tools/gh.ts +169 -2
- package/src/tools/image-gen.ts +69 -7
- package/src/tools/index.ts +7 -5
- package/src/tools/read.ts +48 -3
- package/src/tools/write.ts +22 -4
- package/src/tools/xdev.ts +14 -3
- package/src/utils/title-generator.ts +15 -4
- package/src/web/search/providers/kimi.ts +18 -12
- 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.#
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.#
|
|
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
|
}
|