@oh-my-pi/pi-coding-agent 17.0.5 → 17.0.7
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 +172 -0
- package/dist/cli.js +2838 -2917
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/modes/components/status-line/component.d.ts +8 -0
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
- package/dist/types/modes/setup-wizard/index.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +23 -5
- package/dist/types/session/session-paths.d.ts +21 -4
- package/dist/types/tools/xdev.d.ts +5 -3
- package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
- package/dist/types/vibe/runtime.d.ts +23 -0
- package/package.json +12 -12
- package/src/config/model-discovery.ts +3 -2
- package/src/config/model-registry.ts +6 -2
- package/src/exec/bash-executor.ts +68 -8
- package/src/extensibility/extensions/load-errors.ts +13 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
- package/src/main.ts +8 -0
- package/src/modes/components/chat-transcript-builder.ts +10 -1
- package/src/modes/components/status-line/component.ts +55 -1
- package/src/modes/components/usage-row.ts +17 -1
- package/src/modes/controllers/command-controller.ts +41 -1
- package/src/modes/controllers/event-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +62 -52
- package/src/modes/noninteractive-dispose.test.ts +2 -0
- package/src/modes/print-mode.ts +60 -0
- package/src/modes/rpc/rpc-client.ts +76 -35
- package/src/modes/rpc/rpc-frame.ts +156 -0
- package/src/modes/rpc/rpc-mode.ts +4 -2
- package/src/modes/setup-wizard/index.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +134 -0
- package/src/modes/utils/ui-helpers.ts +6 -1
- package/src/prompts/system/workflow-notice.md +89 -74
- package/src/session/agent-session.ts +89 -26
- package/src/session/session-manager.ts +34 -3
- package/src/session/session-paths.ts +38 -9
- package/src/task/executor.ts +45 -0
- package/src/tools/xdev.ts +11 -4
- package/src/tools/yield.ts +4 -1
- package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
- package/src/vibe/__tests__/token-rate.test.ts +96 -0
- package/src/vibe/runtime.ts +50 -0
- /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
|
@@ -11,13 +11,13 @@ import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/activ
|
|
|
11
11
|
import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
|
|
12
12
|
import * as git from "../../../utils/git";
|
|
13
13
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
14
|
+
import { calculateTokensPerSecond } from "../../../utils/token-rate";
|
|
14
15
|
import { sanitizeStatusText } from "../../shared";
|
|
15
16
|
import { theme } from "../../theme/theme";
|
|
16
17
|
import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
|
|
17
18
|
import { getPreset } from "./presets";
|
|
18
19
|
import { renderSegment, type SegmentContext } from "./segments";
|
|
19
20
|
import { getSeparator } from "./separators";
|
|
20
|
-
import { calculateTokensPerSecond } from "./token-rate";
|
|
21
21
|
import type {
|
|
22
22
|
CollabStatus,
|
|
23
23
|
EffectiveStatusLineSettings,
|
|
@@ -276,6 +276,13 @@ export class StatusLineComponent implements Component {
|
|
|
276
276
|
#loopModeStatus: SegmentContext["loopMode"] = null;
|
|
277
277
|
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
278
278
|
#vibeModeStatus: { enabled: boolean } | null = null;
|
|
279
|
+
/**
|
|
280
|
+
* Injected aggregator that returns the aggregate tok/s of this session's
|
|
281
|
+
* live vibe worker sessions, or null when no workers are streaming. Kept as
|
|
282
|
+
* a callback so the render layer doesn't import the heavy vibe/task
|
|
283
|
+
* dependency graph; interactive-mode wires it to VibeSessionRegistry.
|
|
284
|
+
*/
|
|
285
|
+
#vibeWorkerTokenRate: (() => number | null) | null = null;
|
|
279
286
|
#collabStatus: CollabStatus | null = null;
|
|
280
287
|
#focusedAgentId: string | undefined;
|
|
281
288
|
#activeRepoCache: ActiveRepoCache | undefined;
|
|
@@ -508,6 +515,17 @@ export class StatusLineComponent implements Component {
|
|
|
508
515
|
this.#vibeModeStatus = status ?? null;
|
|
509
516
|
}
|
|
510
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Inject the aggregator that returns the aggregate tok/s of this session's
|
|
520
|
+
* live vibe worker sessions (null when no workers are streaming). Wired by
|
|
521
|
+
* interactive-mode, which owns the VibeSessionRegistry coupling, so the
|
|
522
|
+
* render layer stays off the heavy vibe/task dependency graph. Pass
|
|
523
|
+
* `undefined` to clear.
|
|
524
|
+
*/
|
|
525
|
+
setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void {
|
|
526
|
+
this.#vibeWorkerTokenRate = provider ?? null;
|
|
527
|
+
}
|
|
528
|
+
|
|
511
529
|
setCollabStatus(status: CollabStatus | null): void {
|
|
512
530
|
this.#collabStatus = status;
|
|
513
531
|
}
|
|
@@ -747,6 +765,31 @@ export class StatusLineComponent implements Component {
|
|
|
747
765
|
}
|
|
748
766
|
|
|
749
767
|
#getTokensPerSecond(): number | null {
|
|
768
|
+
// Aggregate tok/s across the main session AND every live vibe worker.
|
|
769
|
+
// In vibe mode the director is often idle while workers stream, so the
|
|
770
|
+
// main session's own rate alone would show a stale/zero value while
|
|
771
|
+
// parallel work is actively generating tokens.
|
|
772
|
+
const workerRate = this.#getVibeWorkerTokensPerSecond();
|
|
773
|
+
if (workerRate !== null) {
|
|
774
|
+
// At least one worker is streaming — add the director's live rate
|
|
775
|
+
// only when it is itself streaming (a finalized last-turn rate would
|
|
776
|
+
// double-count and overstate throughput).
|
|
777
|
+
const mainRate = this.session.isStreaming ? calculateTokensPerSecond(this.session.state.messages, true) : 0;
|
|
778
|
+
return (mainRate ?? 0) + workerRate;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// No workers streaming — fall back to the main session's own rate with
|
|
782
|
+
// its sticky per-assistant-message cache so the badge doesn't flicker
|
|
783
|
+
// off in the brief gap between stream end and the finalized message.
|
|
784
|
+
return this.#getMainSessionTokensPerSecond();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Main session's tok/s with sticky caching keyed on the last assistant
|
|
789
|
+
* message timestamp. Preserves the pre-aggregation behavior when no vibe
|
|
790
|
+
* workers are active.
|
|
791
|
+
*/
|
|
792
|
+
#getMainSessionTokensPerSecond(): number | null {
|
|
750
793
|
let lastAssistantTimestamp: number | null = null;
|
|
751
794
|
for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
|
|
752
795
|
const message = this.session.state.messages[i];
|
|
@@ -776,6 +819,17 @@ export class StatusLineComponent implements Component {
|
|
|
776
819
|
return null;
|
|
777
820
|
}
|
|
778
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Aggregate tok/s across every live vibe worker session owned by this
|
|
824
|
+
* session. Returns null when no workers are streaming (so the main
|
|
825
|
+
* session's own rate shines through unchanged). The aggregation itself is
|
|
826
|
+
* injected via {@link setVibeWorkerTokenRateProvider} to keep this render
|
|
827
|
+
* layer off the heavy vibe/task dependency graph.
|
|
828
|
+
*/
|
|
829
|
+
#getVibeWorkerTokensPerSecond(): number | null {
|
|
830
|
+
return this.#vibeWorkerTokenRate?.() ?? null;
|
|
831
|
+
}
|
|
832
|
+
|
|
779
833
|
#getUsageContextKey(session: AgentSession): string {
|
|
780
834
|
const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
|
|
781
835
|
if (!activeProvider) return "";
|
|
@@ -6,9 +6,25 @@ import { theme } from "../../modes/theme/theme";
|
|
|
6
6
|
/** Below this the rate is nonsense (cached/instant responses yield absurd tok/s). */
|
|
7
7
|
const MIN_DURATION_MS = 100;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** Local `YYYY-MM-DD HH:mm:ss` stamp for the per-turn usage row. */
|
|
10
|
+
function formatUsageTimestamp(ms: number): string {
|
|
11
|
+
const d = new Date(ms);
|
|
12
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
13
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
14
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
15
|
+
return `${date} ${time}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// `timestamp` is optional and trails the throughput args to preserve the existing
|
|
19
|
+
// (usage, durationMs, ttftMs) call contract — this function is part of the package's
|
|
20
|
+
// public export surface (./modes/components/*).
|
|
21
|
+
export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): Container {
|
|
10
22
|
const totalInput = usage.input + usage.cacheWrite;
|
|
11
23
|
const parts: string[] = [];
|
|
24
|
+
// Lead with the turn's local wall-clock time (down to the second), log-line style.
|
|
25
|
+
if (timestamp !== undefined && Number.isFinite(timestamp) && timestamp > 0) {
|
|
26
|
+
parts.push(formatUsageTimestamp(timestamp));
|
|
27
|
+
}
|
|
12
28
|
parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
|
|
13
29
|
parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
|
|
14
30
|
if (usage.cacheRead > 0) {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
14
14
|
import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
15
15
|
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
|
|
16
|
+
import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
|
|
16
17
|
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
|
|
17
18
|
import { shareSession } from "../../export/share";
|
|
18
19
|
import type { CompactOptions } from "../../extensibility/extensions/types";
|
|
@@ -1100,6 +1101,12 @@ export class CommandController {
|
|
|
1100
1101
|
|
|
1101
1102
|
async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
|
|
1102
1103
|
const isDeferred = this.ctx.session.isStreaming;
|
|
1104
|
+
const shouldPersistCwd = isPersistentShellCdCommand(command);
|
|
1105
|
+
if (isDeferred && shouldPersistCwd) {
|
|
1106
|
+
this.ctx.showWarning("Wait for the current response to finish or abort it before changing directories.");
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1103
1110
|
this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
|
|
1104
1111
|
|
|
1105
1112
|
if (isDeferred) {
|
|
@@ -1120,7 +1127,6 @@ export class CommandController {
|
|
|
1120
1127
|
},
|
|
1121
1128
|
{ excludeFromContext, useUserShell: true },
|
|
1122
1129
|
);
|
|
1123
|
-
|
|
1124
1130
|
if (this.ctx.bashComponent) {
|
|
1125
1131
|
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
|
|
1126
1132
|
this.ctx.bashComponent.setComplete(result.exitCode, result.cancelled, {
|
|
@@ -1128,6 +1134,15 @@ export class CommandController {
|
|
|
1128
1134
|
truncation: meta?.truncation,
|
|
1129
1135
|
});
|
|
1130
1136
|
}
|
|
1137
|
+
try {
|
|
1138
|
+
if (shouldPersistCwd) await this.#applyBashResultCwd(result);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
this.ctx.showError(
|
|
1141
|
+
`Bash command completed, but OMP failed to update its working directory: ${
|
|
1142
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
1143
|
+
}`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1131
1146
|
} catch (error) {
|
|
1132
1147
|
if (this.ctx.bashComponent) {
|
|
1133
1148
|
this.ctx.bashComponent.setComplete(undefined, false);
|
|
@@ -1139,6 +1154,31 @@ export class CommandController {
|
|
|
1139
1154
|
this.ctx.ui.requestRender();
|
|
1140
1155
|
}
|
|
1141
1156
|
|
|
1157
|
+
async #moveInteractiveCwd(resolvedPath: string): Promise<void> {
|
|
1158
|
+
await this.ctx.sessionManager.moveTo(resolvedPath);
|
|
1159
|
+
await this.ctx.applyCwdChange(resolvedPath);
|
|
1160
|
+
this.ctx.updateEditorBorderColor();
|
|
1161
|
+
await this.ctx.reloadTodos();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async #applyBashResultCwd(result: BashResult): Promise<void> {
|
|
1165
|
+
if (result.cancelled || result.exitCode !== 0 || !result.workingDir) return;
|
|
1166
|
+
if (!path.isAbsolute(result.workingDir)) return;
|
|
1167
|
+
|
|
1168
|
+
const resolvedPath = path.resolve(result.workingDir);
|
|
1169
|
+
if (resolvedPath === path.resolve(this.ctx.sessionManager.getCwd())) return;
|
|
1170
|
+
|
|
1171
|
+
let isDirectory = false;
|
|
1172
|
+
try {
|
|
1173
|
+
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
|
|
1174
|
+
} catch {
|
|
1175
|
+
isDirectory = false;
|
|
1176
|
+
}
|
|
1177
|
+
if (!isDirectory) return;
|
|
1178
|
+
|
|
1179
|
+
await this.#moveInteractiveCwd(resolvedPath);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1142
1182
|
async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
|
|
1143
1183
|
const isDeferred = this.ctx.session.isStreaming;
|
|
1144
1184
|
this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
|
|
@@ -912,7 +912,12 @@ export class EventController {
|
|
|
912
912
|
this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
|
|
913
913
|
if (settings.get("display.showTokenUsage") && assistantUsageIsBilled(event.message.usage)) {
|
|
914
914
|
this.ctx.chatContainer.addChild(
|
|
915
|
-
createUsageRowBlock(
|
|
915
|
+
createUsageRowBlock(
|
|
916
|
+
event.message.usage,
|
|
917
|
+
event.message.duration,
|
|
918
|
+
event.message.ttft,
|
|
919
|
+
event.message.timestamp,
|
|
920
|
+
),
|
|
916
921
|
);
|
|
917
922
|
}
|
|
918
923
|
this.ctx.streamingComponent = undefined;
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
type Agent,
|
|
9
9
|
AgentBusyError,
|
|
10
10
|
type AgentMessage,
|
|
11
|
-
type AgentToolResult,
|
|
12
11
|
EventLoopKeepalive,
|
|
13
12
|
ThinkingLevel,
|
|
14
13
|
} from "@oh-my-pi/pi-agent-core";
|
|
@@ -89,12 +88,7 @@ import {
|
|
|
89
88
|
MCP_CONNECTION_STATUS_EVENT_CHANNEL,
|
|
90
89
|
type McpConnectionStatusEvent,
|
|
91
90
|
} from "../mcp/startup-events";
|
|
92
|
-
import {
|
|
93
|
-
humanizePlanTitle,
|
|
94
|
-
type PlanApprovalDetails,
|
|
95
|
-
resolveApprovedPlan,
|
|
96
|
-
resolvePlanTitle,
|
|
97
|
-
} from "../plan-mode/approved-plan";
|
|
91
|
+
import { humanizePlanTitle, type PlanApprovalDetails, resolvePlanTitle } from "../plan-mode/approved-plan";
|
|
98
92
|
import { resolvePlanModelTransition } from "../plan-mode/model-transition";
|
|
99
93
|
import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
|
|
100
94
|
import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
|
|
@@ -129,7 +123,6 @@ import {
|
|
|
129
123
|
setActiveTodoDescriptionsProvider,
|
|
130
124
|
todoMatchesAnyDescription,
|
|
131
125
|
} from "../tools/todo";
|
|
132
|
-
import { ToolError } from "../tools/tool-errors";
|
|
133
126
|
import { vocalizer } from "../tts/vocalizer";
|
|
134
127
|
import { renderTreeList } from "../tui/tree-list";
|
|
135
128
|
import { copyToClipboard } from "../utils/clipboard";
|
|
@@ -138,7 +131,7 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
|
138
131
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
|
139
132
|
import { messageHasDisplayableThinking } from "../utils/thinking-display";
|
|
140
133
|
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
|
141
|
-
import { VibeSessionRegistry } from "../vibe/runtime";
|
|
134
|
+
import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../vibe/runtime";
|
|
142
135
|
import type { AssistantMessageComponent } from "./components/assistant-message";
|
|
143
136
|
import type { BashExecutionComponent } from "./components/bash-execution";
|
|
144
137
|
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
|
|
@@ -730,6 +723,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
730
723
|
this.editorContainer.addChild(this.editor);
|
|
731
724
|
this.statusLine = new StatusLineComponent(session);
|
|
732
725
|
this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
|
|
726
|
+
// Vibe worker tok/s aggregator — keeps the status-line render layer off
|
|
727
|
+
// the heavy vibe/task dependency graph. The director is often idle while
|
|
728
|
+
// workers stream, so without this the tok/s badge would show a stale
|
|
729
|
+
// value while parallel work is actively generating tokens.
|
|
730
|
+
this.statusLine.setVibeWorkerTokenRateProvider(() =>
|
|
731
|
+
aggregateVibeWorkerTokensPerSecond(this.session.getAgentId() ?? MAIN_AGENT_ID),
|
|
732
|
+
);
|
|
733
733
|
// Lazy provider — the top border rebuild coalesces to at most one
|
|
734
734
|
// invocation per painted frame instead of firing on every session event
|
|
735
735
|
// (#4145). The TUI throttles renders at ~30fps, so a long-running eval
|
|
@@ -2325,7 +2325,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2325
2325
|
}
|
|
2326
2326
|
if (sessionContext.mode === "plan") {
|
|
2327
2327
|
const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
|
|
2328
|
-
await this.#enterPlanMode({ planFilePath });
|
|
2328
|
+
await this.#enterPlanMode({ planFilePath, preserveRestoredModel: true });
|
|
2329
2329
|
} else if (sessionContext.mode === "plan_paused") {
|
|
2330
2330
|
this.planModePaused = true;
|
|
2331
2331
|
this.#planModeHasEntered = true;
|
|
@@ -2333,7 +2333,11 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2333
2333
|
}
|
|
2334
2334
|
}
|
|
2335
2335
|
|
|
2336
|
-
async #enterPlanMode(options?: {
|
|
2336
|
+
async #enterPlanMode(options?: {
|
|
2337
|
+
planFilePath?: string;
|
|
2338
|
+
workflow?: "parallel" | "iterative";
|
|
2339
|
+
preserveRestoredModel?: boolean;
|
|
2340
|
+
}): Promise<void> {
|
|
2337
2341
|
if (this.planModeEnabled) {
|
|
2338
2342
|
return;
|
|
2339
2343
|
}
|
|
@@ -2380,44 +2384,22 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2380
2384
|
workflow: options?.workflow ?? "parallel",
|
|
2381
2385
|
reentry: this.#planModeHasEntered,
|
|
2382
2386
|
});
|
|
2383
|
-
this.session.setPlanProposalHandler?.(title => this
|
|
2387
|
+
this.session.setPlanProposalHandler?.(title => this.session.preparePlanForReview(title));
|
|
2384
2388
|
if (this.session.isStreaming) {
|
|
2385
2389
|
await this.session.sendPlanModeContext({ deliverAs: "steer" });
|
|
2386
2390
|
}
|
|
2387
2391
|
this.#planModeHasEntered = true;
|
|
2388
|
-
|
|
2392
|
+
// Session loading already restored the model recorded in the journal.
|
|
2393
|
+
// Reapplying today's plan role here would replace a CLI/session-specific
|
|
2394
|
+
// selection with current config during --resume or an in-process switch.
|
|
2395
|
+
if (!options?.preserveRestoredModel) {
|
|
2396
|
+
await this.#applyPlanModeModel();
|
|
2397
|
+
}
|
|
2389
2398
|
this.#updatePlanModeStatus();
|
|
2390
2399
|
this.sessionManager.appendModeChange("plan", { planFilePath });
|
|
2391
2400
|
this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
|
|
2392
2401
|
}
|
|
2393
2402
|
|
|
2394
|
-
/** Plan-proposal handler registered while plan mode is active. The agent
|
|
2395
|
-
* submits the finalized plan by writing the chosen `<slug>`/title to
|
|
2396
|
-
* `xd://propose`; this handler validates the plan file exists, normalizes
|
|
2397
|
-
* the title, and shapes the payload that `event-controller` forwards to
|
|
2398
|
-
* `handlePlanApproval`. */
|
|
2399
|
-
async #handlePlanProposal(title: string): Promise<AgentToolResult<unknown>> {
|
|
2400
|
-
const state = this.session.getPlanModeState?.();
|
|
2401
|
-
if (!state?.enabled) {
|
|
2402
|
-
throw new ToolError("Plan mode is not active.");
|
|
2403
|
-
}
|
|
2404
|
-
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
|
|
2405
|
-
suppliedTitle: title,
|
|
2406
|
-
statePlanFilePath: state.planFilePath,
|
|
2407
|
-
readPlan: url => this.#readPlanFile(url),
|
|
2408
|
-
listPlanFiles: () => this.#listLocalPlanFiles(),
|
|
2409
|
-
});
|
|
2410
|
-
const details: PlanApprovalDetails = {
|
|
2411
|
-
planFilePath,
|
|
2412
|
-
title: resolvedTitle,
|
|
2413
|
-
planExists: true,
|
|
2414
|
-
};
|
|
2415
|
-
return {
|
|
2416
|
-
content: [{ type: "text" as const, text: "Plan ready for approval." }],
|
|
2417
|
-
details,
|
|
2418
|
-
};
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
2403
|
async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }): Promise<void> {
|
|
2422
2404
|
if (modelsAreEqual(this.session.model, prev.model)) {
|
|
2423
2405
|
// Same model — only thinking level may differ. Avoid setModelTemporary()
|
|
@@ -2458,26 +2440,54 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2458
2440
|
}
|
|
2459
2441
|
|
|
2460
2442
|
const planModeState = this.session.getPlanModeState();
|
|
2443
|
+
const planModeTools = this.session.getEnabledToolNames();
|
|
2444
|
+
const planModeMountedTools = this.session.getMountedXdevToolNames();
|
|
2445
|
+
const planModeModelState = this.session.model
|
|
2446
|
+
? { model: this.session.model, thinkingLevel: this.session.configuredThinkingLevel() }
|
|
2447
|
+
: undefined;
|
|
2461
2448
|
this.session.setPlanModeState(undefined);
|
|
2462
2449
|
try {
|
|
2463
2450
|
if (this.#planModePreviousTools !== undefined) {
|
|
2464
2451
|
await this.session.setActiveToolsByName(this.#planModePreviousTools);
|
|
2465
2452
|
}
|
|
2466
|
-
if (this.#planModePreviousModelState) {
|
|
2467
|
-
|
|
2468
|
-
await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
|
|
2469
|
-
}
|
|
2470
|
-
// If #applyPlanModeModel queued a deferred switch to the plan-role model
|
|
2471
|
-
// (because the session was streaming on entry), drop it now: we are
|
|
2472
|
-
// leaving plan mode, so flushing it on the next agent_end would land the
|
|
2473
|
-
// session on the plan-role model after the user has exited plan mode
|
|
2474
|
-
// (issue #816). This runs even when deferModelRestore is set
|
|
2475
|
-
// (compact-approval path): otherwise the stale plan switch survives and
|
|
2476
|
-
// flushPendingModelSwitch() later clobbers the restored/execution model.
|
|
2477
|
-
this.#clearPendingPlanModelSwitch();
|
|
2453
|
+
if (this.#planModePreviousModelState && !options?.deferModelRestore) {
|
|
2454
|
+
await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
|
|
2478
2455
|
}
|
|
2456
|
+
// If #applyPlanModeModel queued a deferred switch to the plan-role model
|
|
2457
|
+
// (because the session was streaming on entry), drop it now: we are
|
|
2458
|
+
// leaving plan mode, so flushing it on the next agent_end would land the
|
|
2459
|
+
// session on the plan-role model after the user has exited plan mode
|
|
2460
|
+
// (issue #816). This runs even when deferModelRestore is set
|
|
2461
|
+
// (compact-approval path): otherwise the stale plan switch survives and
|
|
2462
|
+
// flushPendingModelSwitch() later clobbers the restored/execution model.
|
|
2463
|
+
if (this.#planModePreviousModelState) this.#clearPendingPlanModelSwitch();
|
|
2479
2464
|
} catch (error) {
|
|
2480
2465
|
this.session.setPlanModeState(planModeState);
|
|
2466
|
+
if (
|
|
2467
|
+
planModeModelState &&
|
|
2468
|
+
(!modelsAreEqual(this.session.model, planModeModelState.model) ||
|
|
2469
|
+
this.session.configuredThinkingLevel() !== planModeModelState.thinkingLevel)
|
|
2470
|
+
) {
|
|
2471
|
+
try {
|
|
2472
|
+
await this.#restorePlanPreviousModel(planModeModelState);
|
|
2473
|
+
} catch (rollbackError) {
|
|
2474
|
+
logger.warn("Failed to restore plan model after plan exit failure", { error: String(rollbackError) });
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
const enabledTools = this.session.getEnabledToolNames();
|
|
2478
|
+
const mountedTools = this.session.getMountedXdevToolNames();
|
|
2479
|
+
if (
|
|
2480
|
+
enabledTools.length !== planModeTools.length ||
|
|
2481
|
+
enabledTools.some((name, index) => name !== planModeTools[index]) ||
|
|
2482
|
+
mountedTools.length !== planModeMountedTools.length ||
|
|
2483
|
+
mountedTools.some((name, index) => name !== planModeMountedTools[index])
|
|
2484
|
+
) {
|
|
2485
|
+
try {
|
|
2486
|
+
await this.session.setActiveToolPresentation(planModeTools, planModeMountedTools);
|
|
2487
|
+
} catch (rollbackError) {
|
|
2488
|
+
logger.warn("Failed to restore plan tools after plan exit failure", { error: String(rollbackError) });
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2481
2491
|
throw error;
|
|
2482
2492
|
}
|
|
2483
2493
|
this.session.setPlanProposalHandler?.(null);
|
|
@@ -35,6 +35,8 @@ describe("print-mode error exit disposes the session before exit", () => {
|
|
|
35
35
|
const session = {
|
|
36
36
|
extensionRunner: undefined,
|
|
37
37
|
subscribe: () => {},
|
|
38
|
+
settings: { get: () => false },
|
|
39
|
+
sessionManager: { buildSessionContext: () => ({ messages: [] }), getEntries: () => [] },
|
|
38
40
|
state: { messages: [errorMsg] },
|
|
39
41
|
getLastAssistantMessage: () => errorMsg,
|
|
40
42
|
prepareForHeadlessAdvisorDrain: () => {},
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
9
9
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { resolvePlanModelTransition } from "../plan-mode/model-transition";
|
|
11
12
|
import { type AgentSession, type AgentSessionEvent, SHUTDOWN_CONSOLIDATE_BUDGET_MS } from "../session/agent-session";
|
|
12
13
|
import { isSilentAbort } from "../session/messages";
|
|
13
14
|
import { flushTelemetryExport } from "../telemetry-export";
|
|
15
|
+
import { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../tools/resolve";
|
|
14
16
|
import { initializeExtensions } from "./runtime-init";
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -108,8 +110,66 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|
|
108
110
|
},
|
|
109
111
|
});
|
|
110
112
|
|
|
113
|
+
// InteractiveMode applies the same startup default during TUI initialization.
|
|
114
|
+
// Print mode has no TUI bootstrap, so arm the shared session directly before
|
|
115
|
+
// the first prompt; persisting the mode_change also lets a later interactive
|
|
116
|
+
// attachment restore and review the generated plan.
|
|
117
|
+
let abortAfterPlanProposal = false;
|
|
118
|
+
const planDefaultArmed =
|
|
119
|
+
session.settings.get("plan.defaultOnStartup") &&
|
|
120
|
+
session.settings.get("plan.enabled") &&
|
|
121
|
+
session.sessionManager.buildSessionContext().messages.length === 0 &&
|
|
122
|
+
!session.sessionManager.getEntries().some(entry => entry.type === "mode_change");
|
|
123
|
+
if (planDefaultArmed) {
|
|
124
|
+
const planFilePath = session.getPlanReferencePath() || "local://PLAN.md";
|
|
125
|
+
const previousTools = session.getEnabledToolNames();
|
|
126
|
+
const planTools = session.hasBuiltInTool("write") ? [...new Set([...previousTools, "write"])] : previousTools;
|
|
127
|
+
await session.setActiveToolsByName(planTools);
|
|
128
|
+
session.setPlanModeState({
|
|
129
|
+
enabled: true,
|
|
130
|
+
planFilePath,
|
|
131
|
+
workflow: "parallel",
|
|
132
|
+
});
|
|
133
|
+
session.sessionManager.appendModeChange("plan", { planFilePath });
|
|
134
|
+
abortAfterPlanProposal = true;
|
|
135
|
+
session.setPlanProposalHandler(async title => {
|
|
136
|
+
const result = await session.preparePlanForReview(title);
|
|
137
|
+
const details = result.details;
|
|
138
|
+
if (details) {
|
|
139
|
+
const state = session.getPlanModeState();
|
|
140
|
+
if (state?.enabled) {
|
|
141
|
+
session.setPlanModeState({ ...state, planFilePath: details.planFilePath });
|
|
142
|
+
}
|
|
143
|
+
session.sessionManager.appendModeChange("plan", { planFilePath: details.planFilePath });
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const resolved = session.resolveRoleModelWithThinking("plan");
|
|
149
|
+
const transition = resolvePlanModelTransition(session.model, resolved, false);
|
|
150
|
+
if (transition.kind === "thinking") {
|
|
151
|
+
session.setThinkingLevel(transition.thinkingLevel);
|
|
152
|
+
} else if (transition.kind === "apply") {
|
|
153
|
+
try {
|
|
154
|
+
await session.setModelTemporary(transition.model, transition.thinkingLevel);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.warn("Failed to switch to plan model for print mode", { error: String(error) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
111
161
|
// Always subscribe to enable session persistence via _handleAgentEvent
|
|
112
162
|
session.subscribe(event => {
|
|
163
|
+
if (abortAfterPlanProposal && event.type === "tool_execution_end" && !event.isError) {
|
|
164
|
+
const dispatch = writeDeviceDispatch(event.toolName, event.result);
|
|
165
|
+
if (dispatch?.tool === PROPOSE_DEVICE_NAME && dispatch.mode === "execute") {
|
|
166
|
+
abortAfterPlanProposal = false;
|
|
167
|
+
session.markPlanInternalAbortPending();
|
|
168
|
+
void session.abort().finally(() => {
|
|
169
|
+
session.clearPlanInternalAbortPending();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
113
173
|
// In JSON mode, output all events
|
|
114
174
|
if (mode === "json") {
|
|
115
175
|
process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`);
|
|
@@ -206,6 +206,7 @@ function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): A
|
|
|
206
206
|
|
|
207
207
|
export class RpcClient {
|
|
208
208
|
#process: ptree.ChildProcess | null = null;
|
|
209
|
+
#reaping: Promise<void> | null = null;
|
|
209
210
|
#eventListeners: RpcEventListener[] = [];
|
|
210
211
|
#sessionEventListeners: RpcSessionEventListener[] = [];
|
|
211
212
|
#subagentLifecycleListeners = new Set<RpcSubagentLifecycleListener>();
|
|
@@ -233,6 +234,7 @@ export class RpcClient {
|
|
|
233
234
|
* retry without leaking processes.
|
|
234
235
|
*/
|
|
235
236
|
async start(): Promise<void> {
|
|
237
|
+
await this.#reaping;
|
|
236
238
|
if (this.#process) {
|
|
237
239
|
throw new Error("Client already started");
|
|
238
240
|
}
|
|
@@ -268,7 +270,26 @@ export class RpcClient {
|
|
|
268
270
|
const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
|
|
269
271
|
let readySettled = false;
|
|
270
272
|
|
|
271
|
-
|
|
273
|
+
const reapAfterOutputFailure = async (error: Error) => {
|
|
274
|
+
if (this.#process !== child) return;
|
|
275
|
+
|
|
276
|
+
this.#process = null;
|
|
277
|
+
this.#abortController.abort(error);
|
|
278
|
+
const pendingRequests = Array.from(this.#pendingRequests.values());
|
|
279
|
+
this.#pendingRequests.clear();
|
|
280
|
+
for (const pendingCall of this.#pendingHostToolCalls.values()) pendingCall.controller.abort(error);
|
|
281
|
+
this.#pendingHostToolCalls.clear();
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
child.kill();
|
|
285
|
+
} catch {
|
|
286
|
+
// The process may already have exited.
|
|
287
|
+
}
|
|
288
|
+
await this.#waitForExit(child);
|
|
289
|
+
for (const request of pendingRequests) request.reject(error);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Process lines in background, intercepting the ready signal.
|
|
272
293
|
const lines = readJsonl(child.stdout, this.#abortController.signal);
|
|
273
294
|
void (async () => {
|
|
274
295
|
for await (const line of lines) {
|
|
@@ -279,22 +300,38 @@ export class RpcClient {
|
|
|
279
300
|
}
|
|
280
301
|
this.#handleLine(line);
|
|
281
302
|
}
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// rejecting here would snapshot a partial stderr tail and lose
|
|
286
|
-
// the actual startup error.
|
|
287
|
-
if (readySettled) return;
|
|
288
|
-
await child.exited.catch(() => {});
|
|
303
|
+
// A closed stdout is terminal even if the child remains alive. Startup
|
|
304
|
+
// failures are reaped by the readyPromise catch below; established
|
|
305
|
+
// workers are reaped here so pending requests cannot hang indefinitely.
|
|
289
306
|
if (!readySettled) {
|
|
290
307
|
readySettled = true;
|
|
291
|
-
readyReject(new Error(`Agent
|
|
308
|
+
readyReject(new Error(`Agent output stream ended before ready. Stderr: ${child.peekStderr()}`));
|
|
309
|
+
return;
|
|
292
310
|
}
|
|
293
|
-
|
|
311
|
+
const exitResult = await Promise.race([
|
|
312
|
+
child.exited.then(
|
|
313
|
+
exitCode => ({ exitCode }),
|
|
314
|
+
cause => ({ cause }),
|
|
315
|
+
),
|
|
316
|
+
Bun.sleep(100).then(() => null),
|
|
317
|
+
]);
|
|
318
|
+
const error =
|
|
319
|
+
exitResult === null
|
|
320
|
+
? new Error(`Agent output stream ended unexpectedly. Stderr: ${child.peekStderr()}`)
|
|
321
|
+
: "exitCode" in exitResult
|
|
322
|
+
? new Error(`Agent process exited with code ${exitResult.exitCode}. Stderr: ${child.peekStderr()}`)
|
|
323
|
+
: new Error(`Agent output stream ended. Stderr: ${child.peekStderr()}`, {
|
|
324
|
+
cause: exitResult.cause,
|
|
325
|
+
});
|
|
326
|
+
await reapAfterOutputFailure(error);
|
|
327
|
+
})().catch(async (cause: unknown) => {
|
|
328
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
294
329
|
if (!readySettled) {
|
|
295
330
|
readySettled = true;
|
|
296
|
-
readyReject(
|
|
331
|
+
readyReject(error);
|
|
332
|
+
return;
|
|
297
333
|
}
|
|
334
|
+
await reapAfterOutputFailure(new Error(`Agent output reader failed: ${error.message}`, { cause: error }));
|
|
298
335
|
});
|
|
299
336
|
|
|
300
337
|
// Also race against process exit (in case stdout closes before we read it)
|
|
@@ -325,20 +362,12 @@ export class RpcClient {
|
|
|
325
362
|
if (this.#customTools.length > 0) {
|
|
326
363
|
await this.setCustomTools(this.#customTools);
|
|
327
364
|
}
|
|
328
|
-
} catch (
|
|
329
|
-
// Startup failed after
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
} catch {
|
|
335
|
-
// best-effort cleanup
|
|
336
|
-
}
|
|
337
|
-
this.#abortController.abort();
|
|
338
|
-
if (this.#process === child) {
|
|
339
|
-
this.#process = null;
|
|
340
|
-
}
|
|
341
|
-
throw err;
|
|
365
|
+
} catch (cause) {
|
|
366
|
+
// Startup failed after spawning the child. Reap it before returning
|
|
367
|
+
// so a retry cannot inherit a live worker or its session lock.
|
|
368
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
369
|
+
await reapAfterOutputFailure(error);
|
|
370
|
+
throw cause;
|
|
342
371
|
} finally {
|
|
343
372
|
clearTimeout(readyTimeout);
|
|
344
373
|
}
|
|
@@ -347,28 +376,40 @@ export class RpcClient {
|
|
|
347
376
|
/**
|
|
348
377
|
* Stop the RPC agent process.
|
|
349
378
|
*/
|
|
350
|
-
stop() {
|
|
351
|
-
if (!this.#process) return;
|
|
379
|
+
stop(): Promise<void> {
|
|
380
|
+
if (!this.#process) return this.#reaping ?? Promise.resolve();
|
|
352
381
|
|
|
353
|
-
|
|
354
|
-
this.#
|
|
382
|
+
const error = new Error("Client stopped");
|
|
383
|
+
const child = this.#process;
|
|
384
|
+
child.kill();
|
|
385
|
+
this.#abortController.abort(error);
|
|
355
386
|
this.#process = null;
|
|
387
|
+
for (const request of this.#pendingRequests.values()) request.reject(error);
|
|
356
388
|
this.#pendingRequests.clear();
|
|
357
389
|
for (const pendingCall of this.#pendingHostToolCalls.values()) {
|
|
358
|
-
pendingCall.controller.abort();
|
|
390
|
+
pendingCall.controller.abort(error);
|
|
359
391
|
}
|
|
360
392
|
this.#pendingHostToolCalls.clear();
|
|
393
|
+
return this.#waitForExit(child);
|
|
361
394
|
}
|
|
362
395
|
|
|
363
396
|
/**
|
|
364
397
|
* Stop the RPC agent process and clean up resources.
|
|
365
398
|
*/
|
|
366
399
|
[Symbol.dispose](): void {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
400
|
+
void this.stop();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#waitForExit(child: ptree.ChildProcess): Promise<void> {
|
|
404
|
+
const reaping = child.exited.then(
|
|
405
|
+
() => {},
|
|
406
|
+
() => {},
|
|
407
|
+
);
|
|
408
|
+
this.#reaping = reaping;
|
|
409
|
+
void reaping.then(() => {
|
|
410
|
+
if (this.#reaping === reaping) this.#reaping = null;
|
|
411
|
+
});
|
|
412
|
+
return reaping;
|
|
372
413
|
}
|
|
373
414
|
|
|
374
415
|
/**
|