@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -0
- package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
- package/dist/cli.js +9554 -12379
- package/dist/template-8vdv6xb4.js +1671 -0
- package/dist/template-f8wx9vfn.css +1355 -0
- package/dist/template-qat058wr.html +55 -0
- package/dist/tool-views.generated-jdfmzwmn.js +35 -0
- package/dist/types/advisor/advise-tool.d.ts +0 -7
- package/dist/types/advisor/runtime.d.ts +8 -11
- package/dist/types/advisor/transcript-recorder.d.ts +15 -0
- package/dist/types/async/job-manager.d.ts +11 -0
- package/dist/types/auto-thinking/classifier.d.ts +9 -2
- package/dist/types/cleanse/agent.d.ts +19 -0
- package/dist/types/cleanse/balance.d.ts +7 -0
- package/dist/types/cleanse/checkers.d.ts +13 -0
- package/dist/types/cleanse/index.d.ts +16 -0
- package/dist/types/cleanse/loop.d.ts +16 -0
- package/dist/types/cleanse/parsers.d.ts +13 -0
- package/dist/types/cleanse/progress.d.ts +14 -0
- package/dist/types/cleanse/types.d.ts +64 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/session-picker.d.ts +14 -7
- package/dist/types/collab/guest.d.ts +21 -2
- package/dist/types/commands/cleanse.d.ts +23 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +63 -8
- package/dist/types/cursor-bridge-tools.d.ts +54 -0
- package/dist/types/cursor.d.ts +193 -8
- package/dist/types/edit/edit-clipboard.d.ts +19 -0
- package/dist/types/edit/hashline/diff.d.ts +9 -1
- package/dist/types/edit/index.d.ts +8 -1
- package/dist/types/edit/streaming.d.ts +8 -1
- package/dist/types/export/html/index.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +19 -2
- package/dist/types/extensibility/extensions/types.d.ts +25 -1
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
- package/dist/types/internal-urls/local-protocol.d.ts +3 -2
- package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
- package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
- package/dist/types/launch/terminal-output-worker.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +33 -4
- package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
- package/dist/types/modes/components/read-tool-group.d.ts +12 -0
- package/dist/types/modes/components/session-selector.d.ts +6 -0
- package/dist/types/modes/components/status-line/component.d.ts +23 -0
- package/dist/types/modes/components/tool-execution.d.ts +16 -7
- package/dist/types/modes/components/usage-row.d.ts +2 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +8 -5
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
- package/dist/types/modes/theme/theme.d.ts +12 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/sdk.d.ts +1 -0
- package/dist/types/session/agent-session-events.d.ts +2 -0
- package/dist/types/session/agent-session-types.d.ts +36 -1
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/async-job-delivery.d.ts +8 -0
- package/dist/types/session/claude-session-store.d.ts +13 -0
- package/dist/types/session/codex-session-store.d.ts +14 -0
- package/dist/types/session/credential-pin.d.ts +58 -0
- package/dist/types/session/foreign-session-import.d.ts +15 -0
- package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
- package/dist/types/session/foreign-session-store.d.ts +23 -0
- package/dist/types/session/model-controls.d.ts +3 -3
- package/dist/types/session/session-advisors.d.ts +47 -2
- package/dist/types/session/session-entries.d.ts +19 -1
- package/dist/types/session/session-handoff.d.ts +4 -1
- package/dist/types/session/session-loader.d.ts +2 -0
- package/dist/types/session/session-maintenance.d.ts +1 -1
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/session-tools.d.ts +25 -1
- package/dist/types/session/turn-recovery.d.ts +10 -4
- package/dist/types/system-prompt.d.ts +2 -1
- package/dist/types/task/executor.d.ts +11 -2
- package/dist/types/task/isolation-ownership.d.ts +34 -0
- package/dist/types/task/render.d.ts +7 -0
- package/dist/types/thinking.d.ts +18 -6
- package/dist/types/tools/acp-bridge.d.ts +28 -5
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
- package/dist/types/tools/browser/tab-worker.d.ts +1 -4
- package/dist/types/tools/grep.d.ts +17 -1
- package/dist/types/tools/hub/launch.d.ts +5 -1
- package/dist/types/tools/index.d.ts +4 -1
- package/dist/types/tools/path-utils.d.ts +19 -0
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/xdev.d.ts +2 -1
- package/dist/types/tui/output-block.d.ts +5 -5
- package/dist/types/utils/changelog.d.ts +19 -0
- package/dist/types/utils/git.d.ts +10 -0
- package/dist/types/utils/jj.d.ts +11 -4
- package/dist/types/web/search/types.d.ts +2 -2
- package/package.json +16 -12
- package/scripts/bundle-dist.ts +3 -1
- package/src/advisor/advise-tool.ts +0 -11
- package/src/advisor/runtime.ts +138 -24
- package/src/advisor/transcript-recorder.ts +56 -0
- package/src/async/job-manager.ts +30 -7
- package/src/auto-thinking/classifier.ts +48 -10
- package/src/cleanse/agent.ts +226 -0
- package/src/cleanse/balance.ts +79 -0
- package/src/cleanse/checkers.ts +996 -0
- package/src/cleanse/index.ts +190 -0
- package/src/cleanse/loop.ts +51 -0
- package/src/cleanse/parsers.ts +726 -0
- package/src/cleanse/progress.ts +50 -0
- package/src/cleanse/prompts/assignment.md +47 -0
- package/src/cleanse/types.ts +72 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/flag-tables.ts +2 -0
- package/src/cli/gallery-fixtures/fs.ts +25 -0
- package/src/cli/session-picker.ts +34 -17
- package/src/cli/ttsr-cli.ts +19 -1
- package/src/cli/update-cli.ts +3 -0
- package/src/cli/usage-cli.ts +29 -4
- package/src/cli/worktree-cli.ts +28 -11
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +11 -1
- package/src/collab/guest.ts +37 -4
- package/src/collab/host.ts +1 -0
- package/src/commands/cleanse.ts +45 -0
- package/src/commands/launch.ts +6 -0
- package/src/config/model-registry.ts +103 -25
- package/src/config/settings-schema.ts +65 -6
- package/src/config/settings.ts +25 -0
- package/src/cursor-bridge-tools.ts +81 -0
- package/src/cursor.ts +463 -12
- package/src/discovery/claude-plugins.ts +144 -34
- package/src/edit/edit-clipboard.ts +23 -0
- package/src/edit/hashline/diff.ts +49 -10
- package/src/edit/hashline/execute.ts +38 -6
- package/src/edit/hashline/filesystem.ts +27 -3
- package/src/edit/index.ts +12 -2
- package/src/edit/renderer.ts +4 -4
- package/src/edit/streaming.ts +17 -2
- package/src/export/html/index.ts +17 -10
- package/src/export/html/template.js +1 -1
- package/src/extensibility/extensions/runner.ts +70 -0
- package/src/extensibility/extensions/types.ts +30 -0
- package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
- package/src/internal-urls/local-protocol.ts +3 -2
- package/src/internal-urls/mcp-protocol.ts +4 -1
- package/src/launch/broker.ts +14 -4
- package/src/launch/terminal-output-worker-client.ts +53 -0
- package/src/launch/terminal-output-worker-protocol.ts +11 -0
- package/src/launch/terminal-output-worker.ts +23 -0
- package/src/lsp/client.ts +18 -3
- package/src/lsp/index.ts +4 -0
- package/src/lsp/render.ts +1 -1
- package/src/main.ts +127 -56
- package/src/mcp/manager.ts +120 -18
- package/src/modes/acp/acp-agent.ts +27 -18
- package/src/modes/acp/acp-event-mapper.ts +38 -4
- package/src/modes/components/chat-transcript-builder.ts +30 -11
- package/src/modes/components/codex-reset-fireworks.ts +369 -0
- package/src/modes/components/read-tool-group.ts +159 -17
- package/src/modes/components/session-selector.ts +19 -3
- package/src/modes/components/settings-selector.ts +9 -1
- package/src/modes/components/status-line/component.ts +381 -54
- package/src/modes/components/tool-execution.ts +78 -31
- package/src/modes/components/usage-row.ts +10 -5
- package/src/modes/controllers/event-controller.ts +26 -4
- package/src/modes/controllers/mcp-command-controller.ts +68 -3
- package/src/modes/controllers/selector-controller.ts +94 -36
- package/src/modes/controllers/tan-command-controller.ts +14 -0
- package/src/modes/interactive-mode.ts +67 -64
- package/src/modes/prompt-action-autocomplete.ts +5 -3
- package/src/modes/rpc/rpc-client.ts +18 -0
- package/src/modes/rpc/rpc-mode.ts +16 -1
- package/src/modes/rpc/rpc-types.ts +11 -0
- package/src/modes/theme/theme.ts +151 -6
- package/src/modes/types.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +38 -21
- package/src/prompts/goals/guided-goal-interview.md +41 -6
- package/src/prompts/system/auto-thinking-difficulty.md +4 -2
- package/src/prompts/system/plan-mode-active.md +2 -2
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/system/vibe-mode-active.md +4 -1
- package/src/prompts/system/xdev-mount-notice.md +1 -1
- package/src/prompts/tools/bash.md +15 -17
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/glob.md +7 -6
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/grep.md +7 -6
- package/src/sdk.ts +178 -15
- package/src/session/agent-session-events.ts +1 -0
- package/src/session/agent-session-types.ts +40 -1
- package/src/session/agent-session.ts +330 -179
- package/src/session/async-job-delivery.ts +8 -0
- package/src/session/claude-session-store.ts +426 -0
- package/src/session/codex-session-store.ts +673 -0
- package/src/session/credential-pin.ts +93 -0
- package/src/session/foreign-session-import.ts +52 -0
- package/src/session/foreign-session-jsonl.ts +29 -0
- package/src/session/foreign-session-store.ts +26 -0
- package/src/session/model-controls.ts +15 -7
- package/src/session/session-advisors.ts +142 -23
- package/src/session/session-entries.ts +21 -1
- package/src/session/session-handoff.ts +20 -3
- package/src/session/session-history-format.ts +31 -6
- package/src/session/session-loader.ts +25 -9
- package/src/session/session-maintenance.ts +59 -11
- package/src/session/session-manager.ts +61 -0
- package/src/session/session-tools.ts +107 -5
- package/src/session/settings-stream-fn.ts +1 -0
- package/src/session/turn-recovery.ts +97 -51
- package/src/slash-commands/builtin-registry.ts +136 -5
- package/src/system-prompt.ts +3 -2
- package/src/task/executor.ts +17 -4
- package/src/task/isolation-ownership.ts +106 -0
- package/src/task/render.ts +20 -4
- package/src/task/worktree.ts +8 -0
- package/src/thinking.ts +29 -10
- package/src/tools/acp-bridge.ts +52 -8
- package/src/tools/ask.ts +3 -3
- package/src/tools/ast-edit.ts +9 -2
- package/src/tools/browser/cmux/cmux-tab.ts +9 -14
- package/src/tools/browser/tab-worker.ts +12 -35
- package/src/tools/browser.ts +7 -2
- package/src/tools/checkpoint.ts +0 -13
- package/src/tools/gh-renderer.ts +3 -3
- package/src/tools/grep.ts +64 -8
- package/src/tools/hub/launch.ts +10 -15
- package/src/tools/index.ts +38 -5
- package/src/tools/path-utils.ts +88 -0
- package/src/tools/read.ts +3 -0
- package/src/tools/write.ts +44 -3
- package/src/tools/xdev.ts +39 -14
- package/src/tui/code-cell.ts +4 -4
- package/src/tui/output-block.ts +25 -8
- package/src/utils/changelog.ts +144 -4
- package/src/utils/git.ts +61 -14
- package/src/utils/jj.ts +19 -13
- package/src/utils/shell-snapshot-fn-env.sh +5 -2
- package/src/web/search/providers/codex.ts +34 -0
- package/src/web/search/render.ts +2 -2
- package/src/web/search/types.ts +1 -1
- package/dist/types/goals/guided-setup.d.ts +0 -30
- package/src/goals/guided-setup.ts +0 -171
- package/src/prompts/goals/guided-goal-system.md +0 -33
|
@@ -117,6 +117,7 @@ export interface TurnRecoveryHost {
|
|
|
117
117
|
scheduleAgentContinue(options: { delayMs?: number; generation?: number; onError?: (error: unknown) => void }): void;
|
|
118
118
|
waitForSessionMessagePersistence(message: AssistantMessage): Promise<void>;
|
|
119
119
|
appendSessionMessage(message: AssistantMessage): void;
|
|
120
|
+
persistedAssistantEntryId(message: AssistantMessage): string | undefined;
|
|
120
121
|
sessionMessageAlreadyPersisted(message: AssistantMessage): boolean;
|
|
121
122
|
setModelWithProviderSessionReset(model: Model): Promise<void>;
|
|
122
123
|
resetCurrentResponsesProviderSession(reason: string): void;
|
|
@@ -151,6 +152,12 @@ type PendingRecoveredRetryError = {
|
|
|
151
152
|
note: string;
|
|
152
153
|
};
|
|
153
154
|
|
|
155
|
+
type UsageLimitOutcome = {
|
|
156
|
+
switchedCredential: boolean;
|
|
157
|
+
retryAfterMs: number;
|
|
158
|
+
retryAtMs: number | undefined;
|
|
159
|
+
};
|
|
160
|
+
|
|
154
161
|
/** Owns terminal-stop recovery, automatic retries, and fallback routing. */
|
|
155
162
|
export class TurnRecovery {
|
|
156
163
|
readonly #host: TurnRecoveryHost;
|
|
@@ -160,6 +167,7 @@ export class TurnRecovery {
|
|
|
160
167
|
#retryResolve: (() => void) | undefined;
|
|
161
168
|
#activeRetryFallback: ActiveRetryFallbackState | undefined;
|
|
162
169
|
#pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = [];
|
|
170
|
+
#usageLimitOutcomes = new WeakMap<AssistantMessage, Promise<UsageLimitOutcome>>();
|
|
163
171
|
#emptyStopRetryCount = 0;
|
|
164
172
|
#unexpectedStopRetryCount = 0;
|
|
165
173
|
#acceptTerminalEmptyStopForPrompt = false;
|
|
@@ -297,6 +305,40 @@ export class TurnRecovery {
|
|
|
297
305
|
return this.#handleRetryableError(message, options);
|
|
298
306
|
}
|
|
299
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Records a usage-limit failure before replay eligibility decides whether the
|
|
310
|
+
* failed turn may be discarded. Returns whether credential recovery switched
|
|
311
|
+
* the active account.
|
|
312
|
+
*/
|
|
313
|
+
async recordUsageLimitOutcome(message: AssistantMessage): Promise<boolean> {
|
|
314
|
+
if (message.stopReason !== "error") return false;
|
|
315
|
+
const id = this.#classifyRetryMessage(message);
|
|
316
|
+
const activeModel = this.#host.model();
|
|
317
|
+
if (!activeModel || !AIError.is(id, AIError.Flag.UsageLimit)) return false;
|
|
318
|
+
|
|
319
|
+
let recorded = this.#usageLimitOutcomes.get(message);
|
|
320
|
+
if (!recorded) {
|
|
321
|
+
const errorMessage = message.errorMessage || "Unknown error";
|
|
322
|
+
const retryAfterMs =
|
|
323
|
+
this.#parseRetryAfterMsFromError(errorMessage) ??
|
|
324
|
+
calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
325
|
+
recorded = (async (): Promise<UsageLimitOutcome> => {
|
|
326
|
+
const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
|
|
327
|
+
activeModel.provider,
|
|
328
|
+
this.#host.sessionId(),
|
|
329
|
+
{ retryAfterMs, baseUrl: activeModel.baseUrl, modelId: activeModel.id },
|
|
330
|
+
);
|
|
331
|
+
return {
|
|
332
|
+
switchedCredential: outcome.switched,
|
|
333
|
+
retryAfterMs,
|
|
334
|
+
retryAtMs: outcome.retryAtMs,
|
|
335
|
+
};
|
|
336
|
+
})();
|
|
337
|
+
this.#usageLimitOutcomes.set(message, recorded);
|
|
338
|
+
}
|
|
339
|
+
return (await recorded).switchedCredential;
|
|
340
|
+
}
|
|
341
|
+
|
|
300
342
|
/** Prompts after transient overlap with a prior agent run. */
|
|
301
343
|
promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
|
|
302
344
|
return this.#promptAgentWithIdleRetry(messages, options);
|
|
@@ -729,16 +771,24 @@ export class TurnRecovery {
|
|
|
729
771
|
discardAssistantTurn(assistantMessage: AssistantMessage): void {
|
|
730
772
|
this.removeAssistantMessageFromActiveContext(assistantMessage);
|
|
731
773
|
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
774
|
+
const branch = this.#host.sessionManager.getBranch();
|
|
775
|
+
const persistedEntryId = this.#host.persistedAssistantEntryId(assistantMessage);
|
|
776
|
+
const branchEntry =
|
|
777
|
+
(persistedEntryId === undefined
|
|
778
|
+
? undefined
|
|
779
|
+
: branch.find(
|
|
780
|
+
entry =>
|
|
781
|
+
entry.id === persistedEntryId && entry.type === "message" && entry.message.role === "assistant",
|
|
782
|
+
)) ??
|
|
783
|
+
branch
|
|
784
|
+
.slice()
|
|
785
|
+
.reverse()
|
|
786
|
+
.find(
|
|
787
|
+
entry =>
|
|
788
|
+
entry.type === "message" &&
|
|
789
|
+
entry.message.role === "assistant" &&
|
|
790
|
+
this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
|
|
791
|
+
);
|
|
742
792
|
if (!branchEntry) {
|
|
743
793
|
return;
|
|
744
794
|
}
|
|
@@ -757,7 +807,9 @@ export class TurnRecovery {
|
|
|
757
807
|
(left.timestamp === right.timestamp &&
|
|
758
808
|
left.provider === right.provider &&
|
|
759
809
|
left.model === right.model &&
|
|
760
|
-
left.stopReason === right.stopReason
|
|
810
|
+
left.stopReason === right.stopReason &&
|
|
811
|
+
left.errorMessage === right.errorMessage &&
|
|
812
|
+
Bun.hash(JSON.stringify(left.content)) === Bun.hash(JSON.stringify(right.content)))
|
|
761
813
|
);
|
|
762
814
|
}
|
|
763
815
|
|
|
@@ -831,8 +883,13 @@ export class TurnRecovery {
|
|
|
831
883
|
const contextWindow = this.#host.model()?.contextWindow ?? 0;
|
|
832
884
|
if (AIError.isContextOverflow(message, contextWindow)) return false;
|
|
833
885
|
|
|
886
|
+
// A classifier refusal/sensitivity stop is the model's decision, not a route
|
|
887
|
+
// failure, but only after we confirm no user-visible output has already been
|
|
888
|
+
// streamed. Visible text, images, tool calls, or server tools must not be
|
|
889
|
+
// discarded and replayed.
|
|
890
|
+
if (this.#hasReplayUnsafeOutput(message)) return false;
|
|
834
891
|
if (this.isClassifierRefusal(message)) return true;
|
|
835
|
-
return AIError.retriable(id
|
|
892
|
+
return AIError.retriable(id);
|
|
836
893
|
}
|
|
837
894
|
|
|
838
895
|
/**
|
|
@@ -900,12 +957,22 @@ export class TurnRecovery {
|
|
|
900
957
|
}
|
|
901
958
|
/**
|
|
902
959
|
* Retried turns remove the failed assistant message from active context.
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
960
|
+
* Thinking-only partials are safe to discard and replay: reasoning models
|
|
961
|
+
* routinely stall after long thinking with no visible output, and duplicated
|
|
962
|
+
* thinking display is materially lower harm than duplicated final text.
|
|
963
|
+
* Whitespace-only text is likewise safe since nothing meaningful reached the
|
|
964
|
+
* user. Visible text, generated images, server tools, and retained tool calls
|
|
965
|
+
* are NOT safe: each has already rendered or may have side effects, so replaying
|
|
966
|
+
* the turn can duplicate user-visible output or work.
|
|
906
967
|
*/
|
|
907
|
-
#
|
|
908
|
-
return message.content.some(
|
|
968
|
+
#hasReplayUnsafeOutput(message: AssistantMessage): boolean {
|
|
969
|
+
return message.content.some(
|
|
970
|
+
block =>
|
|
971
|
+
block.type === "toolCall" ||
|
|
972
|
+
block.type === "image" ||
|
|
973
|
+
block.type === "anthropicServerTool" ||
|
|
974
|
+
(block.type === "text" && block.text.trim().length > 0),
|
|
975
|
+
);
|
|
909
976
|
}
|
|
910
977
|
|
|
911
978
|
/**
|
|
@@ -1087,14 +1154,14 @@ export class TurnRecovery {
|
|
|
1087
1154
|
* transient overload/5xx or a hard "router/model not found / unsupported" —
|
|
1088
1155
|
* is worth retrying on the base id. Skips failures the base model shares:
|
|
1089
1156
|
* context overflow (compaction's job), usage limits and auth errors (same
|
|
1090
|
-
* account/key), and turns that already emitted
|
|
1091
|
-
*
|
|
1157
|
+
* account/key), and turns that already emitted any replay-unsafe output.
|
|
1158
|
+
* Requires the base model to exist in the registry.
|
|
1092
1159
|
*/
|
|
1093
1160
|
isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
|
|
1094
1161
|
const model = this.#activeFireworksFastModel();
|
|
1095
1162
|
if (!model) return false;
|
|
1096
1163
|
if (message.stopReason !== "error") return false;
|
|
1097
|
-
if (message
|
|
1164
|
+
if (this.#hasReplayUnsafeOutput(message)) return false;
|
|
1098
1165
|
// A content refusal/sensitivity stop is the model's decision, not a route
|
|
1099
1166
|
// failure — switching to the base model would just re-trigger it.
|
|
1100
1167
|
if (this.isClassifierRefusal(message)) return false;
|
|
@@ -1113,8 +1180,7 @@ export class TurnRecovery {
|
|
|
1113
1180
|
* model switch cannot fix or must not replay: cancellations (abort-flavored
|
|
1114
1181
|
* errors are not model faults), context overflow (compaction's job),
|
|
1115
1182
|
* classifier refusals (chain consult is handled on the retryable path with
|
|
1116
|
-
* `pinFallback`), and turns that already emitted
|
|
1117
|
-
* could duplicate work).
|
|
1183
|
+
* `pinFallback`), and turns that already emitted replay-unsafe output.
|
|
1118
1184
|
*/
|
|
1119
1185
|
isHardErrorFallbackEligible(message: AssistantMessage): boolean {
|
|
1120
1186
|
if (message.stopReason !== "error") return false;
|
|
@@ -1126,7 +1192,7 @@ export class TurnRecovery {
|
|
|
1126
1192
|
const id = this.#classifyRetryMessage(message);
|
|
1127
1193
|
if (AIError.is(id, AIError.Flag.Abort) || AIError.is(id, AIError.Flag.UserInterrupt)) return false;
|
|
1128
1194
|
if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
|
|
1129
|
-
if (this.#
|
|
1195
|
+
if (this.#hasReplayUnsafeOutput(message)) return false;
|
|
1130
1196
|
const currentSelector = formatRetryFallbackSelector(model, this.#host.thinkingLevel());
|
|
1131
1197
|
const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector);
|
|
1132
1198
|
if (!role) return false;
|
|
@@ -1308,6 +1374,7 @@ export class TurnRecovery {
|
|
|
1308
1374
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
1309
1375
|
const id = this.#classifyRetryMessage(message);
|
|
1310
1376
|
const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
|
|
1377
|
+
const recordedUsageLimitOutcome = await this.#usageLimitOutcomes.get(message);
|
|
1311
1378
|
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
|
1312
1379
|
let delayMs = staleOpenAIResponsesReplayError
|
|
1313
1380
|
? 0
|
|
@@ -1322,31 +1389,8 @@ export class TurnRecovery {
|
|
|
1322
1389
|
this.#host.resetCurrentResponsesProviderSession("stale replay error");
|
|
1323
1390
|
}
|
|
1324
1391
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
!retryBudgetExhausted &&
|
|
1328
|
-
activeModel &&
|
|
1329
|
-
!staleOpenAIResponsesReplayError &&
|
|
1330
|
-
AIError.is(id, AIError.Flag.UsageLimit)
|
|
1331
|
-
) {
|
|
1332
|
-
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
1333
|
-
const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
|
|
1334
|
-
activeModel.provider,
|
|
1335
|
-
this.#host.sessionId(),
|
|
1336
|
-
{
|
|
1337
|
-
retryAfterMs,
|
|
1338
|
-
baseUrl: activeModel.baseUrl,
|
|
1339
|
-
modelId: activeModel.id,
|
|
1340
|
-
},
|
|
1341
|
-
);
|
|
1342
|
-
if (outcome.switched) {
|
|
1343
|
-
switchedCredential = true;
|
|
1344
|
-
delayMs = 0;
|
|
1345
|
-
} else if (await this.#host.maybeAutoRedeemCodexReset()) {
|
|
1346
|
-
// A live usage-limit 429 on the active Codex account, with a banked
|
|
1347
|
-
// reset and the opt-in setting on: spend the reset and retry
|
|
1348
|
-
// immediately instead of waiting out the window. Runs after the
|
|
1349
|
-
// free sibling-switch above and before model fallback below.
|
|
1392
|
+
if (!retryBudgetExhausted && !staleOpenAIResponsesReplayError && recordedUsageLimitOutcome) {
|
|
1393
|
+
if (recordedUsageLimitOutcome.switchedCredential || (await this.#host.maybeAutoRedeemCodexReset())) {
|
|
1350
1394
|
switchedCredential = true;
|
|
1351
1395
|
delayMs = 0;
|
|
1352
1396
|
} else {
|
|
@@ -1358,9 +1402,10 @@ export class TurnRecovery {
|
|
|
1358
1402
|
// Without this, one short-lived sibling block escalates a
|
|
1359
1403
|
// recoverable situation into the provider's multi-hour wait and
|
|
1360
1404
|
// trips the fail-fast cap below.
|
|
1361
|
-
usageLimitWaitMs = retryAfterMs;
|
|
1362
|
-
if (
|
|
1363
|
-
const siblingWaitMs =
|
|
1405
|
+
usageLimitWaitMs = recordedUsageLimitOutcome.retryAfterMs;
|
|
1406
|
+
if (recordedUsageLimitOutcome.retryAtMs !== undefined) {
|
|
1407
|
+
const siblingWaitMs =
|
|
1408
|
+
Math.max(0, recordedUsageLimitOutcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS;
|
|
1364
1409
|
if (siblingWaitMs < usageLimitWaitMs) {
|
|
1365
1410
|
usageLimitWaitMs = siblingWaitMs;
|
|
1366
1411
|
}
|
|
@@ -1398,6 +1443,7 @@ export class TurnRecovery {
|
|
|
1398
1443
|
delayMs = parsedRetryAfterMs;
|
|
1399
1444
|
}
|
|
1400
1445
|
}
|
|
1446
|
+
|
|
1401
1447
|
if (retryBudgetExhausted) {
|
|
1402
1448
|
if (!switchedModel) {
|
|
1403
1449
|
await this.persistTerminalEmptyErrorTurn(message);
|
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
5
5
|
import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
|
|
6
|
-
import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { APP_NAME, getMCPConfigPath, getProjectDir, logger, setProjectDir } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import { reset as resetCapabilities } from "../capability";
|
|
8
8
|
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
|
|
9
9
|
import { CollabHost } from "../collab/host";
|
|
@@ -31,8 +31,10 @@ import {
|
|
|
31
31
|
getPluginsCacheDir,
|
|
32
32
|
MarketplaceManager,
|
|
33
33
|
} from "../extensibility/plugins/marketplace";
|
|
34
|
+
import { readMCPConfigFile } from "../mcp/config-writer";
|
|
34
35
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
35
36
|
import { runPauseScreen } from "../modes/components/pause-screen";
|
|
37
|
+
import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller";
|
|
36
38
|
import { describeLoopLimitRuntime } from "../modes/loop-limit";
|
|
37
39
|
import { theme } from "../modes/theme/theme";
|
|
38
40
|
import type { InteractiveModeContext } from "../modes/types";
|
|
@@ -468,12 +470,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
468
470
|
},
|
|
469
471
|
{
|
|
470
472
|
name: "guided-goal",
|
|
471
|
-
description: "
|
|
473
|
+
description: "Have the agent interview you in chat, then set up goal mode",
|
|
472
474
|
inlineHint: "[rough objective]",
|
|
473
475
|
allowArgs: true,
|
|
474
476
|
handleTui: async (command, runtime) => {
|
|
475
|
-
await
|
|
477
|
+
// Clear the slash draft BEFORE the await: the handler blocks for the
|
|
478
|
+
// whole kickoff turn, and a post-await clear would wipe an answer the
|
|
479
|
+
// user starts typing while the first interview question streams.
|
|
476
480
|
runtime.ctx.editor.setText("");
|
|
481
|
+
await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
|
|
477
482
|
},
|
|
478
483
|
},
|
|
479
484
|
{
|
|
@@ -1787,11 +1792,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1787
1792
|
{
|
|
1788
1793
|
name: "resume",
|
|
1789
1794
|
description: "Resume a different session",
|
|
1790
|
-
inlineHint: "[session id]",
|
|
1795
|
+
inlineHint: "[session id|@claude|@codex]",
|
|
1791
1796
|
allowArgs: true,
|
|
1792
1797
|
handleTui: async (command, runtime) => {
|
|
1793
1798
|
const sessionArg = command.args.trim();
|
|
1794
1799
|
runtime.ctx.editor.setText("");
|
|
1800
|
+
const foreignSource = sessionArg === "@claude" ? "claude" : sessionArg === "@codex" ? "codex" : undefined;
|
|
1801
|
+
if (foreignSource) {
|
|
1802
|
+
runtime.ctx.showSessionSelector(foreignSource);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1795
1805
|
if (!sessionArg) {
|
|
1796
1806
|
runtime.ctx.showSessionSelector();
|
|
1797
1807
|
return;
|
|
@@ -2711,6 +2721,124 @@ function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string
|
|
|
2711
2721
|
};
|
|
2712
2722
|
}
|
|
2713
2723
|
|
|
2724
|
+
/** /mcp subcommands whose argument is a server name (per their `usage: "<name>..."`). */
|
|
2725
|
+
const MCP_SERVER_NAME_SUBCOMMANDS: Readonly<Record<string, true>> = {
|
|
2726
|
+
enable: true,
|
|
2727
|
+
disable: true,
|
|
2728
|
+
test: true,
|
|
2729
|
+
remove: true,
|
|
2730
|
+
reconnect: true,
|
|
2731
|
+
reauth: true,
|
|
2732
|
+
unauth: true,
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
/** Subcommands that accept names found only in `userConfig.disabledServers`. */
|
|
2736
|
+
const MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = {
|
|
2737
|
+
enable: true,
|
|
2738
|
+
disable: true,
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
/**
|
|
2742
|
+
* Subcommands that accept configured servers whose `enabled` flag is false.
|
|
2743
|
+
* `unauth` can clear persisted credentials without connecting; test,
|
|
2744
|
+
* reconnect, and reauth explicitly require an enabled server.
|
|
2745
|
+
*/
|
|
2746
|
+
const MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = {
|
|
2747
|
+
enable: true,
|
|
2748
|
+
disable: true,
|
|
2749
|
+
unauth: true,
|
|
2750
|
+
};
|
|
2751
|
+
|
|
2752
|
+
/**
|
|
2753
|
+
* Build getArgumentCompletions for /mcp. Delegates to the generic
|
|
2754
|
+
* declarative subcommand completer while the subcommand name itself is
|
|
2755
|
+
* still being typed, then switches to MCP server-name completion (sourced
|
|
2756
|
+
* from {@link collectMcpServerNames}) once a recognized server-name
|
|
2757
|
+
* subcommand (enable/disable/test/remove/reconnect/reauth/unauth) is
|
|
2758
|
+
* followed by a space. `remove` gets its own scope-aware completions (see
|
|
2759
|
+
* {@link buildMcpRemoveCompletions}) since — unlike the others —
|
|
2760
|
+
* it only ever succeeds against a config-file entry. Subcommands with a
|
|
2761
|
+
* different argument shape (add, smithery-search, ...) get no argument
|
|
2762
|
+
* completion.
|
|
2763
|
+
*/
|
|
2764
|
+
function buildMcpArgumentCompletions(
|
|
2765
|
+
subcommands: SubcommandDef[],
|
|
2766
|
+
runtime: TuiSlashCommandRuntime,
|
|
2767
|
+
): (argumentPrefix: string) => Promise<AutocompleteItem[] | null> {
|
|
2768
|
+
const genericCompletions = buildArgumentCompletions(subcommands);
|
|
2769
|
+
return async (argumentPrefix: string) => {
|
|
2770
|
+
const spaceIndex = argumentPrefix.indexOf(" ");
|
|
2771
|
+
if (spaceIndex === -1) return genericCompletions(argumentPrefix);
|
|
2772
|
+
|
|
2773
|
+
const rawSubcommand = argumentPrefix.slice(0, spaceIndex);
|
|
2774
|
+
const lowerSubcommand = rawSubcommand.toLowerCase();
|
|
2775
|
+
if (MCP_SERVER_NAME_SUBCOMMANDS[lowerSubcommand] !== true) return null;
|
|
2776
|
+
const namePrefix = argumentPrefix.slice(spaceIndex + 1).toLowerCase();
|
|
2777
|
+
if (lowerSubcommand === "remove") {
|
|
2778
|
+
return await buildMcpRemoveCompletions(rawSubcommand, namePrefix);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
let serverNames: string[];
|
|
2782
|
+
try {
|
|
2783
|
+
serverNames = await collectMcpServerNames(
|
|
2784
|
+
runtime.ctx,
|
|
2785
|
+
undefined,
|
|
2786
|
+
MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true,
|
|
2787
|
+
MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true,
|
|
2788
|
+
);
|
|
2789
|
+
} catch (error) {
|
|
2790
|
+
logger.warn("MCP server-name autocomplete failed to read config", { error });
|
|
2791
|
+
return null;
|
|
2792
|
+
}
|
|
2793
|
+
const matches: AutocompleteItem[] = serverNames
|
|
2794
|
+
.filter(name => name.toLowerCase().startsWith(namePrefix))
|
|
2795
|
+
.map(name => ({ value: `${rawSubcommand} ${name} `, label: name }));
|
|
2796
|
+
return matches.length > 0 ? matches : null;
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/**
|
|
2801
|
+
* Build `/mcp remove <name>` completions. Unlike the other server-name
|
|
2802
|
+
* subcommands, `#handleRemove` only ever succeeds against a config-file
|
|
2803
|
+
* `mcpServers` entry in the target scope (project by default, user with an
|
|
2804
|
+
* explicit `--scope user`) — a purely runtime-discovered server has no
|
|
2805
|
+
* config entry to remove and always fails with `Server "<name>" not found
|
|
2806
|
+
* in <scope> config.`. Completions are therefore restricted to config-file
|
|
2807
|
+
* names, and a name that exists only in the user config is completed with
|
|
2808
|
+
* `--scope user` appended so the inserted command is directly executable.
|
|
2809
|
+
*/
|
|
2810
|
+
async function buildMcpRemoveCompletions(
|
|
2811
|
+
rawSubcommand: string,
|
|
2812
|
+
namePrefix: string,
|
|
2813
|
+
): Promise<AutocompleteItem[] | null> {
|
|
2814
|
+
const cwd = getProjectDir();
|
|
2815
|
+
let projectNames: string[];
|
|
2816
|
+
let userNames: string[];
|
|
2817
|
+
try {
|
|
2818
|
+
const [projectConfig, userConfig] = await Promise.all([
|
|
2819
|
+
readMCPConfigFile(getMCPConfigPath("project", cwd)),
|
|
2820
|
+
readMCPConfigFile(getMCPConfigPath("user", cwd)),
|
|
2821
|
+
]);
|
|
2822
|
+
projectNames = Object.keys(projectConfig.mcpServers ?? {});
|
|
2823
|
+
userNames = Object.keys(userConfig.mcpServers ?? {});
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
logger.warn("MCP remove autocomplete failed to read config", { error });
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
const projectNameSet = new Set(projectNames);
|
|
2830
|
+
const allNames = new Set([...projectNames, ...userNames]);
|
|
2831
|
+
const matches: AutocompleteItem[] = [...allNames]
|
|
2832
|
+
.filter(name => name.toLowerCase().startsWith(namePrefix))
|
|
2833
|
+
.map(name =>
|
|
2834
|
+
projectNameSet.has(name)
|
|
2835
|
+
? { value: `${rawSubcommand} ${name} `, label: name }
|
|
2836
|
+
: { value: `${rawSubcommand} ${name} --scope user `, label: `${name} (user)` },
|
|
2837
|
+
)
|
|
2838
|
+
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
|
2839
|
+
return matches.length > 0 ? matches : null;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2714
2842
|
/**
|
|
2715
2843
|
* Build getInlineHint from declarative subcommand definitions.
|
|
2716
2844
|
* Shows remaining completion + usage as dim ghost text after cursor.
|
|
@@ -2874,7 +3002,10 @@ function materializeTuiBuiltinSlashCommand(
|
|
|
2874
3002
|
): TuiBuiltinSlashCommand {
|
|
2875
3003
|
const materialized: TuiBuiltinSlashCommand = { ...cmd };
|
|
2876
3004
|
if (cmd.subcommands) {
|
|
2877
|
-
materialized.getArgumentCompletions =
|
|
3005
|
+
materialized.getArgumentCompletions =
|
|
3006
|
+
cmd.name === "mcp" && runtime
|
|
3007
|
+
? buildMcpArgumentCompletions(cmd.subcommands, runtime)
|
|
3008
|
+
: buildArgumentCompletions(cmd.subcommands);
|
|
2878
3009
|
materialized.getInlineHint = buildSubcommandInlineHint(cmd.subcommands);
|
|
2879
3010
|
} else if (cmd.name === "move") {
|
|
2880
3011
|
materialized.getArgumentCompletions = buildDirectoryArgumentCompletions();
|
package/src/system-prompt.ts
CHANGED
|
@@ -540,8 +540,8 @@ export interface BuildSystemPromptOptions {
|
|
|
540
540
|
renderMermaid?: boolean;
|
|
541
541
|
/** Pre-resolved nested active repo context. Undefined resolves from cwd. */
|
|
542
542
|
activeRepoContext?: ActiveRepoContext | null;
|
|
543
|
-
/** Tools mounted under `xd://`; renders the protocol section when non-empty. */
|
|
544
|
-
xdevTools?: Array<{ name: string; summary: string }>;
|
|
543
|
+
/** Tools mounted under `xd://`; renders the protocol section when non-empty. `dynamic` marks external devices whose summary is third-party metadata. */
|
|
544
|
+
xdevTools?: Array<{ name: string; summary: string; dynamic?: boolean }>;
|
|
545
545
|
/** Full docs + JSON schema for every `xd://`-mounted tool, inlined into the protocol section so no discovery `read` is needed. */
|
|
546
546
|
xdevDocs?: string;
|
|
547
547
|
/** Whether Auto-QA grievance reporting is enabled; renders the `xd://report_issue` note. */
|
|
@@ -870,6 +870,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
870
870
|
includeWorkspaceTree,
|
|
871
871
|
renderMermaid,
|
|
872
872
|
xdevTools,
|
|
873
|
+
hasDynamicXdevTools: xdevTools.some(mounted => mounted.dynamic === true),
|
|
873
874
|
xdevDocs,
|
|
874
875
|
autoQaEnabled,
|
|
875
876
|
};
|
package/src/task/executor.ts
CHANGED
|
@@ -87,8 +87,10 @@ const MCP_CALL_TIMEOUT_MS = 60_000;
|
|
|
87
87
|
* agent is driven to one forced final `yield` so partial findings come back
|
|
88
88
|
* as a real report; only if it still refuses to yield within
|
|
89
89
|
* {@link BUDGET_STOP_GRACE_REQUESTS} more requests is the run hard-aborted.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* Entries are ceilings, not fixed values: the `default` key applies to agents
|
|
91
|
+
* without an explicit entry, and the `task.softRequestBudget` setting can only
|
|
92
|
+
* lower an agent's budget, never raise it above its bundled entry (0 disables
|
|
93
|
+
* the guard entirely).
|
|
92
94
|
*/
|
|
93
95
|
export const SOFT_REQUEST_BUDGET: Record<string, number> = {
|
|
94
96
|
scout: 100,
|
|
@@ -96,6 +98,18 @@ export const SOFT_REQUEST_BUDGET: Record<string, number> = {
|
|
|
96
98
|
default: 200,
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the effective soft request budget for an agent. The configured
|
|
103
|
+
* `task.softRequestBudget` and the agent's bundled entry are both upper
|
|
104
|
+
* bounds, so the tighter one wins; a configured budget of 0 disables the
|
|
105
|
+
* guard regardless of the bundled entry.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveSoftRequestBudget(agentName: string, configuredBudget: number): number {
|
|
108
|
+
const normalized = Math.max(0, Math.trunc(configuredBudget));
|
|
109
|
+
if (normalized === 0) return 0;
|
|
110
|
+
return Math.min(normalized, SOFT_REQUEST_BUDGET[agentName] ?? normalized);
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
/** Extra requests allowed after a budget stop for the forced yield to land before the run is hard-aborted. */
|
|
100
114
|
export const BUDGET_STOP_GRACE_REQUESTS = 5;
|
|
101
115
|
|
|
@@ -2446,8 +2460,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2446
2460
|
0,
|
|
2447
2461
|
Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
|
|
2448
2462
|
);
|
|
2449
|
-
const softRequestBudget =
|
|
2450
|
-
configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
|
|
2463
|
+
const softRequestBudget = resolveSoftRequestBudget(agent.name, configuredDefaultBudget);
|
|
2451
2464
|
const softRequestBudgetNotice = settings.get("task.softRequestBudgetNotice") ?? false;
|
|
2452
2465
|
const parentDepth = options.taskDepth ?? 0;
|
|
2453
2466
|
const childDepth = parentDepth + 1;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership marker for task-isolation sandboxes under `~/.omp/wt/`.
|
|
3
|
+
*
|
|
4
|
+
* Each isolation base dir (`ensureIsolation` in {@link ./worktree}) holds a
|
|
5
|
+
* compact `m` mount plus this marker file naming the omp process that created
|
|
6
|
+
* it. `omp worktree clear` consults the marker so it can distinguish a live
|
|
7
|
+
* subagent's sandbox from a crashed run's leftover instead of deleting both.
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { $ } from "bun";
|
|
11
|
+
|
|
12
|
+
/** Marker file written into a task-isolation base dir identifying its owner. */
|
|
13
|
+
export const ISOLATION_OWNER_FILE = ".omp-isolation-owner.json";
|
|
14
|
+
|
|
15
|
+
/** Recorded owner of a task-isolation sandbox. */
|
|
16
|
+
export interface IsolationOwner {
|
|
17
|
+
/** PID of the omp process that created and owns the sandbox. */
|
|
18
|
+
pid: number;
|
|
19
|
+
/** Task id the sandbox was materialised for. */
|
|
20
|
+
id: string;
|
|
21
|
+
/**
|
|
22
|
+
* Process-instance start-time token for {@link pid}, when the OS can report
|
|
23
|
+
* it. Distinguishes the owning process from an unrelated process that later
|
|
24
|
+
* inherits a recycled pid, so a crashed sandbox is never pinned live.
|
|
25
|
+
*/
|
|
26
|
+
startToken?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Boot-stable start-time token for `pid`, or `null` when the process is gone or
|
|
31
|
+
* the platform cannot report it. Read from the same source on write and
|
|
32
|
+
* validate so an exact string compare rejects a recycled pid.
|
|
33
|
+
*
|
|
34
|
+
* Linux reads `/proc/<pid>/stat` field 22 (start time in clock ticks since
|
|
35
|
+
* boot); other Unixes shell out to `ps -o lstart`. Platforms that report
|
|
36
|
+
* neither (e.g. Windows) yield `null`, degrading to a pid-only liveness check.
|
|
37
|
+
*/
|
|
38
|
+
async function processStartToken(pid: number): Promise<string | null> {
|
|
39
|
+
if (process.platform === "linux") {
|
|
40
|
+
let stat: string;
|
|
41
|
+
try {
|
|
42
|
+
stat = await Bun.file(`/proc/${pid}/stat`).text();
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// The comm field (2) may embed spaces and parens, so parse the numeric
|
|
47
|
+
// fields after the final ')'. `starttime` is field 22 overall, i.e. the
|
|
48
|
+
// 20th token once `pid` and `(comm)` are dropped.
|
|
49
|
+
const commEnd = stat.lastIndexOf(")");
|
|
50
|
+
if (commEnd < 0) return null;
|
|
51
|
+
const starttime = stat.slice(commEnd + 2).split(" ")[19];
|
|
52
|
+
return starttime && starttime.length > 0 ? starttime : null;
|
|
53
|
+
}
|
|
54
|
+
const res = await $`ps -o lstart= -p ${pid}`.quiet().nothrow();
|
|
55
|
+
if (res.exitCode !== 0) return null;
|
|
56
|
+
const started = res.text().trim();
|
|
57
|
+
return started.length > 0 ? started : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record the current process as owner of the sandbox rooted at `baseDir`.
|
|
62
|
+
*
|
|
63
|
+
* Written before the isolation backend materialises `m` so a concurrent
|
|
64
|
+
* `omp worktree clear` never sees an owner-less sandbox mid-creation.
|
|
65
|
+
*/
|
|
66
|
+
export async function writeIsolationOwner(baseDir: string, id: string): Promise<void> {
|
|
67
|
+
const startToken = await processStartToken(process.pid);
|
|
68
|
+
const owner: IsolationOwner = { pid: process.pid, id, ...(startToken ? { startToken } : {}) };
|
|
69
|
+
await Bun.write(path.join(baseDir, ISOLATION_OWNER_FILE), JSON.stringify(owner));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a live omp process still owns the sandbox at `baseDir`.
|
|
74
|
+
*
|
|
75
|
+
* A missing or malformed marker means no verifiable owner — a crashed run or a
|
|
76
|
+
* sandbox from before markers existed, both safe to reclaim. `process.kill(pid,
|
|
77
|
+
* 0)` can fail with `EPERM` even when the process is alive, so only an explicit
|
|
78
|
+
* `ESRCH` ("no such process") counts as dead; any other error is treated as
|
|
79
|
+
* alive to avoid deleting a sandbox that is actually in use. When the marker
|
|
80
|
+
* carries a {@link IsolationOwner.startToken}, a live pid whose current token no
|
|
81
|
+
* longer matches is a recycled pid — a different process — and counts as dead.
|
|
82
|
+
*/
|
|
83
|
+
export async function hasLiveIsolationOwner(baseDir: string): Promise<boolean> {
|
|
84
|
+
let decoded: unknown;
|
|
85
|
+
try {
|
|
86
|
+
decoded = await Bun.file(path.join(baseDir, ISOLATION_OWNER_FILE)).json();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (typeof decoded !== "object" || decoded === null || !("pid" in decoded)) return false;
|
|
91
|
+
const pid = decoded.pid;
|
|
92
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
97
|
+
}
|
|
98
|
+
// The pid is live (or unknowable via EPERM). Reject a recycled pid: if the
|
|
99
|
+
// marker pinned the owner's start-time token, the process wearing that pid
|
|
100
|
+
// now must still present the same token.
|
|
101
|
+
if ("startToken" in decoded && typeof decoded.startToken === "string" && decoded.startToken.length > 0) {
|
|
102
|
+
const current = await processStartToken(pid);
|
|
103
|
+
if (current !== null && current !== decoded.startToken) return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|