@oh-my-pi/pi-coding-agent 15.7.1 → 15.7.3
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 +95 -6
- package/dist/types/auto-thinking/classifier.d.ts +35 -0
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/cli/extension-flags.d.ts +36 -0
- package/dist/types/config/config-file.d.ts +4 -0
- package/dist/types/config/file-lock.d.ts +23 -0
- package/dist/types/config/keybindings.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +112 -69
- package/dist/types/edit/hashline/diff.d.ts +9 -3
- package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
- package/dist/types/eval/agent-bridge.d.ts +25 -0
- package/dist/types/eval/backend.d.ts +17 -2
- package/dist/types/eval/budget-bridge.d.ts +29 -0
- package/dist/types/eval/idle-timeout.d.ts +28 -0
- package/dist/types/eval/js/executor.d.ts +8 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/eval/py/executor.d.ts +13 -0
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
- package/dist/types/extensibility/plugins/manager.d.ts +12 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/memory-backend/index.d.ts +1 -1
- package/dist/types/memory-backend/resolve.d.ts +1 -1
- package/dist/types/memory-backend/types.d.ts +3 -3
- package/dist/types/mnemopi/backend.d.ts +4 -0
- package/dist/types/mnemopi/config.d.ts +29 -0
- package/dist/types/mnemopi/state.d.ts +72 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -2
- package/dist/types/modes/components/model-selector.d.ts +3 -2
- package/dist/types/modes/components/omfg-panel.d.ts +19 -0
- package/dist/types/modes/controllers/command-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -3
- package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
- package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
- package/dist/types/modes/gradient-highlight.d.ts +5 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -3
- package/dist/types/modes/magic-keywords.d.ts +14 -0
- package/dist/types/modes/markdown-prose.d.ts +27 -0
- package/dist/types/modes/orchestrate.d.ts +7 -2
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/turn-budget.d.ts +18 -0
- package/dist/types/modes/types.d.ts +7 -3
- package/dist/types/modes/ultrathink.d.ts +7 -2
- package/dist/types/modes/workflow.d.ts +15 -0
- package/dist/types/sdk.d.ts +15 -4
- package/dist/types/session/agent-session.d.ts +59 -23
- package/dist/types/session/session-manager.d.ts +18 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/session/shake-types.d.ts +24 -0
- package/dist/types/task/executor.d.ts +2 -2
- package/dist/types/thinking.d.ts +39 -1
- package/dist/types/tiny/device.d.ts +3 -3
- package/dist/types/tiny/models.d.ts +34 -1
- package/dist/types/tiny/title-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +19 -3
- package/dist/types/tools/memory-edit.d.ts +1 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/package.json +10 -10
- package/src/auto-thinking/classifier.ts +180 -0
- package/src/autoresearch/tools/run-experiment.ts +45 -113
- package/src/cli/args.ts +39 -16
- package/src/cli/extension-flags.ts +48 -0
- package/src/cli/plugin-cli.ts +11 -2
- package/src/config/config-file.ts +98 -13
- package/src/config/file-lock.ts +60 -17
- package/src/config/keybindings.ts +78 -27
- package/src/config/model-registry.ts +7 -1
- package/src/config/settings-schema.ts +118 -71
- package/src/config/settings.ts +12 -0
- package/src/edit/hashline/diff.ts +87 -22
- package/src/edit/renderer.ts +16 -12
- package/src/edit/streaming.ts +17 -6
- package/src/eval/__tests__/agent-bridge.test.ts +433 -0
- package/src/eval/__tests__/budget-bridge.test.ts +69 -0
- package/src/eval/__tests__/idle-timeout.test.ts +66 -0
- package/src/eval/__tests__/shared-executors.test.ts +53 -0
- package/src/eval/agent-bridge.ts +295 -0
- package/src/eval/backend.ts +17 -2
- package/src/eval/budget-bridge.ts +48 -0
- package/src/eval/idle-timeout.ts +80 -0
- package/src/eval/js/executor.ts +35 -7
- package/src/eval/js/index.ts +2 -1
- package/src/eval/js/shared/local-module-loader.ts +75 -10
- package/src/eval/js/shared/prelude.txt +85 -1
- package/src/eval/js/tool-bridge.ts +9 -0
- package/src/eval/py/executor.ts +41 -14
- package/src/eval/py/index.ts +2 -1
- package/src/eval/py/prelude.py +132 -1
- package/src/exec/bash-executor.ts +2 -3
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/runner.ts +12 -2
- package/src/extensibility/plugins/git-url.ts +90 -4
- package/src/extensibility/plugins/manager.ts +103 -7
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +88 -88
- package/src/main.ts +50 -56
- package/src/memory-backend/index.ts +1 -1
- package/src/memory-backend/resolve.ts +3 -3
- package/src/memory-backend/types.ts +3 -3
- package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
- package/src/{mnemosyne → mnemopi}/config.ts +36 -36
- package/src/{mnemosyne → mnemopi}/state.ts +67 -67
- package/src/modes/acp/acp-agent.ts +13 -3
- package/src/modes/components/agent-dashboard.ts +6 -6
- package/src/modes/components/custom-editor.ts +4 -11
- package/src/modes/components/extensions/state-manager.ts +3 -4
- package/src/modes/components/footer.ts +18 -12
- package/src/modes/components/hook-selector.ts +86 -20
- package/src/modes/components/model-selector.ts +20 -11
- package/src/modes/components/oauth-selector.ts +93 -21
- package/src/modes/components/omfg-panel.ts +141 -0
- package/src/modes/components/settings-defs.ts +9 -2
- package/src/modes/components/settings-selector.ts +4 -1
- package/src/modes/components/status-line/segments.ts +13 -5
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +38 -19
- package/src/modes/components/tree-selector.ts +4 -3
- package/src/modes/components/user-message-selector.ts +94 -19
- package/src/modes/components/user-message.ts +8 -1
- package/src/modes/controllers/command-controller.ts +57 -0
- package/src/modes/controllers/event-controller.ts +65 -3
- package/src/modes/controllers/input-controller.ts +14 -11
- package/src/modes/controllers/omfg-controller.ts +283 -0
- package/src/modes/controllers/omfg-rule.ts +647 -0
- package/src/modes/controllers/selector-controller.ts +21 -6
- package/src/modes/gradient-highlight.ts +23 -6
- package/src/modes/interactive-mode.ts +41 -7
- package/src/modes/magic-keywords.ts +20 -0
- package/src/modes/markdown-prose.ts +247 -0
- package/src/modes/orchestrate.ts +17 -11
- package/src/modes/shared.ts +3 -11
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/turn-budget.ts +31 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/ultrathink.ts +16 -10
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/workflow.ts +42 -0
- package/src/prompts/system/auto-thinking-difficulty-local.md +14 -0
- package/src/prompts/system/auto-thinking-difficulty.md +12 -0
- package/src/prompts/system/omfg-user.md +51 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/system/workflow-notice.md +70 -0
- package/src/prompts/tools/eval.md +13 -1
- package/src/prompts/tools/memory-edit.md +1 -1
- package/src/sdk.ts +86 -38
- package/src/session/agent-session.ts +558 -80
- package/src/session/session-manager.ts +32 -0
- package/src/session/session-storage.ts +68 -8
- package/src/session/shake-types.ts +44 -0
- package/src/slash-commands/builtin-registry.ts +41 -16
- package/src/task/executor.ts +3 -3
- package/src/task/index.ts +6 -6
- package/src/thinking.ts +73 -1
- package/src/tiny/device.ts +4 -10
- package/src/tiny/models.ts +54 -2
- package/src/tiny/title-protocol.ts +11 -1
- package/src/tiny/worker.ts +19 -7
- package/src/tools/eval.ts +202 -26
- package/src/tools/grouped-file-output.ts +9 -2
- package/src/tools/index.ts +17 -5
- package/src/tools/memory-edit.ts +4 -4
- package/src/tools/memory-recall.ts +5 -5
- package/src/tools/memory-reflect.ts +5 -5
- package/src/tools/memory-retain.ts +4 -4
- package/src/tools/render-utils.ts +2 -1
- package/src/tools/search.ts +480 -76
- package/dist/types/mnemosyne/backend.d.ts +0 -4
- package/dist/types/mnemosyne/config.d.ts +0 -29
- package/dist/types/mnemosyne/state.d.ts +0 -72
- /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
- /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-prompt difficulty classifier for the `auto` thinking level.
|
|
3
|
+
*
|
|
4
|
+
* Picks a coding-difficulty bucket for a user prompt and maps it to a concrete
|
|
5
|
+
* {@link Effort}, clamped into the active model's supported range (never below
|
|
6
|
+
* {@link Effort.Low}). Two backends, selected by `providers.autoThinkingModel`:
|
|
7
|
+
*
|
|
8
|
+
* - `online` (default): a smol model classifies into `low|medium|high|xhigh`.
|
|
9
|
+
* - a local key: an on-device memory model classifies into the coarser
|
|
10
|
+
* `trivial|moderate|hard` scheme (3-class is more reliable than 4-way ordinal
|
|
11
|
+
* on sub-2B models), mapped to `low|high|xhigh`.
|
|
12
|
+
*
|
|
13
|
+
* Throws on any failure (no model, no key, unparseable output, abort/timeout);
|
|
14
|
+
* the caller falls back to a concrete level and continues the turn.
|
|
15
|
+
*/
|
|
16
|
+
import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
|
17
|
+
import { prompt } from "@oh-my-pi/pi-utils";
|
|
18
|
+
import type { ModelRegistry } from "../config/model-registry";
|
|
19
|
+
import { resolveRoleSelection } from "../config/model-resolver";
|
|
20
|
+
import type { Settings } from "../config/settings";
|
|
21
|
+
import difficultySystemPrompt from "../prompts/system/auto-thinking-difficulty.md" with { type: "text" };
|
|
22
|
+
import difficultyLocalPrompt from "../prompts/system/auto-thinking-difficulty-local.md" with { type: "text" };
|
|
23
|
+
import { clampAutoThinkingEffort } from "../thinking";
|
|
24
|
+
import { isTinyMemoryLocalModelKey, ONLINE_AUTO_THINKING_MODEL_KEY } from "../tiny/models";
|
|
25
|
+
import { tinyModelClient } from "../tiny/title-client";
|
|
26
|
+
|
|
27
|
+
const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt);
|
|
28
|
+
|
|
29
|
+
/** Upper bound on prompt characters fed to the classifier. */
|
|
30
|
+
const MAX_INPUT_CHARS = 6000;
|
|
31
|
+
const HEAD_CHARS = 4000;
|
|
32
|
+
const TAIL_CHARS = 2000;
|
|
33
|
+
/** The answer is a single word; keep budgets tiny for non-reasoning backends. */
|
|
34
|
+
const ANSWER_MAX_TOKENS = 8;
|
|
35
|
+
/**
|
|
36
|
+
* Reasoning backends ignore `disableReasoning` on some providers, so reserve
|
|
37
|
+
* enough output room for the keyword to still land after unavoidable thinking.
|
|
38
|
+
*/
|
|
39
|
+
const REASONING_SAFE_MAX_TOKENS = 1024;
|
|
40
|
+
|
|
41
|
+
export interface ClassifyDifficultyDeps {
|
|
42
|
+
settings: Settings;
|
|
43
|
+
registry: ModelRegistry;
|
|
44
|
+
model: Model;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify `promptText` and return a concrete effort clamped to `deps.model`.
|
|
52
|
+
* @throws when the backend cannot produce a usable classification.
|
|
53
|
+
*/
|
|
54
|
+
export async function classifyDifficulty(promptText: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
|
|
55
|
+
const backend = deps.settings.get("providers.autoThinkingModel");
|
|
56
|
+
const input = prepareClassifierInput(promptText);
|
|
57
|
+
const effort =
|
|
58
|
+
backend === ONLINE_AUTO_THINKING_MODEL_KEY
|
|
59
|
+
? await classifyOnline(input, deps)
|
|
60
|
+
: await classifyLocal(input, backend, deps);
|
|
61
|
+
return clampAutoThinkingEffort(deps.model, effort);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
|
|
65
|
+
const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
|
|
66
|
+
const model = resolved?.model;
|
|
67
|
+
if (!model) {
|
|
68
|
+
throw new Error("auto-thinking: no smol model available for classification");
|
|
69
|
+
}
|
|
70
|
+
const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
|
|
71
|
+
if (!apiKey) {
|
|
72
|
+
throw new Error(`auto-thinking: no API key for ${model.provider}/${model.id}`);
|
|
73
|
+
}
|
|
74
|
+
// Resolve metadata after getApiKey so the session-sticky credential is recorded first.
|
|
75
|
+
const metadata = deps.metadataResolver?.(model.provider);
|
|
76
|
+
const maxTokens = model.reasoning ? Math.max(ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : ANSWER_MAX_TOKENS;
|
|
77
|
+
|
|
78
|
+
const response = await completeSimple(
|
|
79
|
+
model,
|
|
80
|
+
{
|
|
81
|
+
systemPrompt: [DIFFICULTY_SYSTEM_PROMPT],
|
|
82
|
+
messages: [{ role: "user", content: input, timestamp: Date.now() }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
apiKey,
|
|
86
|
+
maxTokens,
|
|
87
|
+
disableReasoning: true,
|
|
88
|
+
metadata,
|
|
89
|
+
signal: deps.signal,
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (response.stopReason === "error") {
|
|
94
|
+
throw new Error(`auto-thinking: online classification failed: ${response.errorMessage ?? "unknown error"}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const text = extractText(response.content);
|
|
98
|
+
const effort = parseDifficultyLevel(text);
|
|
99
|
+
if (!effort) {
|
|
100
|
+
throw new Error(`auto-thinking: unparseable online classification: ${JSON.stringify(text)}`);
|
|
101
|
+
}
|
|
102
|
+
return effort;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function classifyLocal(input: string, modelKey: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
|
|
106
|
+
if (!isTinyMemoryLocalModelKey(modelKey)) {
|
|
107
|
+
throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`);
|
|
108
|
+
}
|
|
109
|
+
const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input });
|
|
110
|
+
const text = await tinyModelClient.complete(modelKey, builtPrompt, {
|
|
111
|
+
maxTokens: ANSWER_MAX_TOKENS,
|
|
112
|
+
signal: deps.signal,
|
|
113
|
+
});
|
|
114
|
+
if (!text) {
|
|
115
|
+
throw new Error("auto-thinking: local classification returned no output");
|
|
116
|
+
}
|
|
117
|
+
const effort = parseDifficultyBucket(text);
|
|
118
|
+
if (!effort) {
|
|
119
|
+
throw new Error(`auto-thinking: unparseable local classification: ${JSON.stringify(text)}`);
|
|
120
|
+
}
|
|
121
|
+
return effort;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Map the online 4-way level keyword to an {@link Effort}; earliest match wins. */
|
|
125
|
+
export function parseDifficultyLevel(text: string): Effort | undefined {
|
|
126
|
+
const lower = text.toLowerCase();
|
|
127
|
+
const candidates: Array<[number, Effort]> = [];
|
|
128
|
+
// `xhigh` must be probed as its own token: `\bhigh\b` cannot match the "high"
|
|
129
|
+
// inside "xhigh" (no word boundary between `x` and `h`), so the two never collide.
|
|
130
|
+
const xhigh = lower.search(/x[\s_-]?high/);
|
|
131
|
+
if (xhigh >= 0) candidates.push([xhigh, Effort.XHigh]);
|
|
132
|
+
const high = lower.search(/\bhigh\b/);
|
|
133
|
+
if (high >= 0) candidates.push([high, Effort.High]);
|
|
134
|
+
const medium = lower.search(/\bmed(?:ium)?\b/);
|
|
135
|
+
if (medium >= 0) candidates.push([medium, Effort.Medium]);
|
|
136
|
+
const low = lower.search(/\blow\b/);
|
|
137
|
+
if (low >= 0) candidates.push([low, Effort.Low]);
|
|
138
|
+
return earliest(candidates);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Map the local 3-way bucket keyword to an {@link Effort}; earliest match wins. */
|
|
142
|
+
export function parseDifficultyBucket(text: string): Effort | undefined {
|
|
143
|
+
const lower = text.toLowerCase();
|
|
144
|
+
const candidates: Array<[number, Effort]> = [];
|
|
145
|
+
const trivial = lower.search(/\btrivial\b/);
|
|
146
|
+
if (trivial >= 0) candidates.push([trivial, Effort.Low]);
|
|
147
|
+
const moderate = lower.search(/\bmoderate\b/);
|
|
148
|
+
if (moderate >= 0) candidates.push([moderate, Effort.High]);
|
|
149
|
+
const hard = lower.search(/\bhard\b/);
|
|
150
|
+
if (hard >= 0) candidates.push([hard, Effort.XHigh]);
|
|
151
|
+
return earliest(candidates);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function earliest(candidates: Array<[number, Effort]>): Effort | undefined {
|
|
155
|
+
if (candidates.length === 0) return undefined;
|
|
156
|
+
let best = candidates[0];
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
if (candidate[0] < best[0]) best = candidate;
|
|
159
|
+
}
|
|
160
|
+
return best[1];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractText(content: AssistantMessage["content"]): string {
|
|
164
|
+
return content
|
|
165
|
+
.filter((block): block is Extract<AssistantMessage["content"][number], { type: "text" }> => block.type === "text")
|
|
166
|
+
.map(block => block.text)
|
|
167
|
+
.join(" ")
|
|
168
|
+
.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Bound the classifier input. Code blocks are kept (a large diff is signal), but
|
|
173
|
+
* very long prompts are head+tail trimmed so the intent (start) and any trailing
|
|
174
|
+
* error/stacktrace (end) both survive.
|
|
175
|
+
*/
|
|
176
|
+
function prepareClassifierInput(text: string): string {
|
|
177
|
+
const trimmed = text.trim();
|
|
178
|
+
if (trimmed.length <= MAX_INPUT_CHARS) return trimmed;
|
|
179
|
+
return `${trimmed.slice(0, HEAD_CHARS)}\n…\n${trimmed.slice(-TAIL_CHARS)}`;
|
|
180
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import * as childProcess from "node:child_process";
|
|
2
1
|
import * as fs from "node:fs";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
5
4
|
import { formatBytes } from "@oh-my-pi/pi-utils";
|
|
6
5
|
import * as z from "zod/v4";
|
|
6
|
+
import { executeBash } from "../../exec/bash-executor";
|
|
7
7
|
import type { ToolDefinition } from "../../extensibility/extensions";
|
|
8
8
|
import type { Theme } from "../../modes/theme/theme";
|
|
9
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output";
|
|
9
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, truncateTail } from "../../session/streaming-output";
|
|
10
10
|
import { replaceTabs, shortenPath } from "../../tools/render-utils";
|
|
11
11
|
import * as git from "../../utils/git";
|
|
12
12
|
import { parseWorkDirDirtyPaths } from "../git";
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
EXPERIMENT_MAX_LINES,
|
|
16
16
|
formatElapsed,
|
|
17
17
|
formatNum,
|
|
18
|
-
killTree,
|
|
19
18
|
parseAsiLines,
|
|
20
19
|
parseMetricLines,
|
|
21
20
|
tryGitPrefix,
|
|
@@ -117,7 +116,7 @@ export function createRunExperimentTool(
|
|
|
117
116
|
let execution: ProcessExecutionResult;
|
|
118
117
|
try {
|
|
119
118
|
execution = await executeProcess({
|
|
120
|
-
command:
|
|
119
|
+
command: resolvedCommand,
|
|
121
120
|
cwd: ctx.cwd,
|
|
122
121
|
logPath: benchmarkLogPath,
|
|
123
122
|
timeoutMs,
|
|
@@ -268,68 +267,18 @@ export function createRunExperimentTool(
|
|
|
268
267
|
};
|
|
269
268
|
}
|
|
270
269
|
async function executeProcess(opts: {
|
|
271
|
-
command: string
|
|
270
|
+
command: string;
|
|
272
271
|
cwd: string;
|
|
273
272
|
logPath: string;
|
|
274
273
|
timeoutMs: number;
|
|
275
274
|
signal?: AbortSignal;
|
|
276
275
|
onProgress?(details: ProgressSnapshot): void;
|
|
277
276
|
}): Promise<ProcessExecutionResult> {
|
|
278
|
-
const
|
|
279
|
-
const child = childProcess.spawn(opts.command[0] ?? "bash", opts.command.slice(1), {
|
|
280
|
-
cwd: opts.cwd,
|
|
281
|
-
detached: true,
|
|
282
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
const tailChunks: Buffer[] = [];
|
|
286
|
-
let chunksBytes = 0;
|
|
287
|
-
let killedByTimeout = false;
|
|
288
|
-
let resolved = false;
|
|
289
|
-
let writeStream: fs.WriteStream | undefined = fs.createWriteStream(opts.logPath);
|
|
290
|
-
let forceKillTimeout: NodeJS.Timeout | undefined;
|
|
291
|
-
|
|
292
|
-
const closeWriteStream = (): Promise<void> => {
|
|
293
|
-
if (!writeStream) return Promise.resolve();
|
|
294
|
-
const stream = writeStream;
|
|
295
|
-
writeStream = undefined;
|
|
296
|
-
return new Promise<void>((resolveClose, rejectClose) => {
|
|
297
|
-
stream.end((error?: Error | null) => {
|
|
298
|
-
if (error) {
|
|
299
|
-
rejectClose(error);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
resolveClose();
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
const cleanup = (): void => {
|
|
308
|
-
if (progressTimer) clearInterval(progressTimer);
|
|
309
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
310
|
-
if (forceKillTimeout) clearTimeout(forceKillTimeout);
|
|
311
|
-
opts.signal?.removeEventListener("abort", abortHandler);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const finish = (callback: () => void): void => {
|
|
315
|
-
if (resolved) return;
|
|
316
|
-
resolved = true;
|
|
317
|
-
cleanup();
|
|
318
|
-
callback();
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
const appendChunk = (data: Buffer): void => {
|
|
322
|
-
writeStream?.write(data);
|
|
323
|
-
tailChunks.push(data);
|
|
324
|
-
chunksBytes += data.length;
|
|
325
|
-
while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) {
|
|
326
|
-
const removed = tailChunks.shift();
|
|
327
|
-
if (removed) chunksBytes -= removed.length;
|
|
328
|
-
}
|
|
329
|
-
};
|
|
277
|
+
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
|
|
330
278
|
|
|
279
|
+
const startedAt = Date.now();
|
|
331
280
|
const snapshot = (): ProgressSnapshot => {
|
|
332
|
-
const tail = truncateTail(
|
|
281
|
+
const tail = truncateTail(tailBuffer.text(), {
|
|
333
282
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
334
283
|
maxLines: DEFAULT_MAX_LINES,
|
|
335
284
|
});
|
|
@@ -342,71 +291,54 @@ async function executeProcess(opts: {
|
|
|
342
291
|
};
|
|
343
292
|
};
|
|
344
293
|
|
|
345
|
-
const killTreeWithEscalation = (): void => {
|
|
346
|
-
if (!child.pid) return;
|
|
347
|
-
killTree(child.pid);
|
|
348
|
-
forceKillTimeout = setTimeout(() => {
|
|
349
|
-
if (child.pid) killTree(child.pid, "SIGKILL");
|
|
350
|
-
}, 1_000);
|
|
351
|
-
forceKillTimeout.unref?.();
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
const startedAt = Date.now();
|
|
355
294
|
const progressTimer = opts.onProgress
|
|
356
295
|
? setInterval(() => {
|
|
357
296
|
opts.onProgress?.(snapshot());
|
|
358
297
|
}, 1000)
|
|
359
298
|
: undefined;
|
|
360
|
-
const timeoutHandle =
|
|
361
|
-
opts.timeoutMs > 0
|
|
362
|
-
? setTimeout(() => {
|
|
363
|
-
killedByTimeout = true;
|
|
364
|
-
killTreeWithEscalation();
|
|
365
|
-
}, opts.timeoutMs)
|
|
366
|
-
: undefined;
|
|
367
299
|
|
|
368
|
-
const
|
|
369
|
-
|
|
300
|
+
const logSink = Bun.file(opts.logPath).writer();
|
|
301
|
+
let logSinkClosed = false;
|
|
302
|
+
const closeLogSink = async (): Promise<void> => {
|
|
303
|
+
if (logSinkClosed) return;
|
|
304
|
+
logSinkClosed = true;
|
|
305
|
+
await logSink.end();
|
|
370
306
|
};
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
child.on("error", error => {
|
|
384
|
-
void closeWriteStream().finally(() => {
|
|
385
|
-
finish(() => reject(error));
|
|
307
|
+
try {
|
|
308
|
+
const result = await executeBash(opts.command, {
|
|
309
|
+
cwd: opts.cwd,
|
|
310
|
+
sessionKey: `autoresearch:${opts.cwd}`,
|
|
311
|
+
timeout: opts.timeoutMs > 0 ? opts.timeoutMs : 2_147_000_000,
|
|
312
|
+
signal: opts.signal,
|
|
313
|
+
chunkThrottleMs: 0,
|
|
314
|
+
onChunk: chunk => {
|
|
315
|
+
tailBuffer.append(chunk);
|
|
316
|
+
logSink.write(chunk);
|
|
317
|
+
},
|
|
386
318
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
await closeWriteStream();
|
|
391
|
-
if (opts.signal?.aborted) {
|
|
392
|
-
finish(() => reject(new Error("aborted")));
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
const output = await fs.promises.readFile(opts.logPath, "utf8");
|
|
396
|
-
finish(() =>
|
|
397
|
-
resolve({
|
|
398
|
-
exitCode: code,
|
|
399
|
-
killed: killedByTimeout,
|
|
400
|
-
logPath: opts.logPath,
|
|
401
|
-
output,
|
|
402
|
-
}),
|
|
403
|
-
);
|
|
404
|
-
} catch (error) {
|
|
405
|
-
finish(() => reject(error));
|
|
319
|
+
await closeLogSink();
|
|
320
|
+
if (opts.signal?.aborted) {
|
|
321
|
+
throw new Error("aborted");
|
|
406
322
|
}
|
|
407
|
-
});
|
|
408
323
|
|
|
409
|
-
|
|
324
|
+
const output = await fs.promises.readFile(opts.logPath, "utf8");
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
exitCode: result.exitCode ?? null,
|
|
328
|
+
killed: result.cancelled,
|
|
329
|
+
logPath: opts.logPath,
|
|
330
|
+
output,
|
|
331
|
+
};
|
|
332
|
+
} finally {
|
|
333
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
334
|
+
if (!logSinkClosed) {
|
|
335
|
+
try {
|
|
336
|
+
await closeLogSink();
|
|
337
|
+
} catch {
|
|
338
|
+
// Preserve the command failure when cleanup is best-effort.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
410
342
|
}
|
|
411
343
|
|
|
412
344
|
function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string {
|
package/src/cli/args.ts
CHANGED
|
@@ -54,7 +54,12 @@ export interface Args {
|
|
|
54
54
|
unknownFlags: Map<string, boolean | string>;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function parseArgs(
|
|
57
|
+
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
|
58
|
+
// Work on a copy: the `--option=value` handling below splices the value
|
|
59
|
+
// into the array, and callers reuse the same argv (the post-extension
|
|
60
|
+
// reparse in `runRootCommand` parses it a second time). Mutating the input
|
|
61
|
+
// would corrupt that later parse, so never touch the caller's array.
|
|
62
|
+
const args = [...inputArgs];
|
|
58
63
|
const result: Args = {
|
|
59
64
|
messages: [],
|
|
60
65
|
fileArgs: [],
|
|
@@ -63,17 +68,41 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
|
|
63
68
|
|
|
64
69
|
for (let i = 0; i < args.length; i++) {
|
|
65
70
|
let arg = args[i];
|
|
71
|
+
const flagIndex = i;
|
|
66
72
|
|
|
67
|
-
// Support --flag=value syntax (e.g. --tools=ask,read)
|
|
73
|
+
// Support --flag=value syntax (e.g. --tools=ask,read). The value is
|
|
74
|
+
// spliced in as the next token so value-consuming flags pick it up via
|
|
75
|
+
// `args[++i]`; a non-consuming flag (e.g. a boolean) leaves it behind and
|
|
76
|
+
// the post-loop guard drops it so it is not mistaken for a message.
|
|
77
|
+
let equalsValueIndex = -1;
|
|
68
78
|
if (arg.startsWith("--") && arg.includes("=")) {
|
|
69
79
|
const eqIdx = arg.indexOf("=");
|
|
70
80
|
const value = arg.slice(eqIdx + 1);
|
|
71
81
|
arg = arg.slice(0, eqIdx);
|
|
72
|
-
// Insert the value so the existing "args[++i]" logic picks it up
|
|
73
82
|
args.splice(i + 1, 0, value);
|
|
83
|
+
equalsValueIndex = i + 1;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
|
|
86
|
+
// Extension-registered flags take precedence over built-ins: a flag an
|
|
87
|
+
// extension owns (e.g. plan-mode's boolean `--plan`) is parsed with the
|
|
88
|
+
// extension's semantics rather than falling into a built-in branch. For a
|
|
89
|
+
// value-taking built-in (`--plan`, `--model`, …) that branch would consume
|
|
90
|
+
// the following token — eating the user's message and setting the wrong
|
|
91
|
+
// built-in field — so registered flags shadow same-named built-ins here.
|
|
92
|
+
const extFlag = arg.startsWith("--") ? extensionFlags?.get(arg.slice(2)) : undefined;
|
|
93
|
+
if (extFlag) {
|
|
94
|
+
const flagName = arg.slice(2);
|
|
95
|
+
if (extFlag.type === "boolean") {
|
|
96
|
+
result.unknownFlags.set(flagName, true);
|
|
97
|
+
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
|
98
|
+
// Consume the value in `--flag=value` form, or when the next token is
|
|
99
|
+
// not flag-looking. A `-`-prefixed token in space form is left to be
|
|
100
|
+
// its own flag; pass a flag-looking value as `--flag=value`.
|
|
101
|
+
if (equalsValueIndex !== -1 || !args[i + 1].startsWith("-")) {
|
|
102
|
+
result.unknownFlags.set(flagName, args[++i]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
77
106
|
result.help = true;
|
|
78
107
|
} else if (arg === "--version" || arg === "-v") {
|
|
79
108
|
result.version = true;
|
|
@@ -198,21 +227,15 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
|
|
198
227
|
}
|
|
199
228
|
} else if (arg.startsWith("@")) {
|
|
200
229
|
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
|
201
|
-
} else if (arg.startsWith("--") && extensionFlags) {
|
|
202
|
-
// Check if it's an extension-registered flag
|
|
203
|
-
const flagName = arg.slice(2);
|
|
204
|
-
const extFlag = extensionFlags.get(flagName);
|
|
205
|
-
if (extFlag) {
|
|
206
|
-
if (extFlag.type === "boolean") {
|
|
207
|
-
result.unknownFlags.set(flagName, true);
|
|
208
|
-
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
|
209
|
-
result.unknownFlags.set(flagName, args[++i]);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
// Unknown flags without extensionFlags are silently ignored (first pass)
|
|
213
230
|
} else if (!arg.startsWith("-")) {
|
|
214
231
|
result.messages.push(arg);
|
|
215
232
|
}
|
|
233
|
+
// Drop an unconsumed `--flag=value` value (e.g. a boolean flag): when no
|
|
234
|
+
// branch advanced past the spliced token, remove it so it does not fall
|
|
235
|
+
// through to a later iteration and become a positional message.
|
|
236
|
+
if (equalsValueIndex !== -1 && i === flagIndex) {
|
|
237
|
+
args.splice(equalsValueIndex, 1);
|
|
238
|
+
}
|
|
216
239
|
}
|
|
217
240
|
|
|
218
241
|
return result;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Args, parseArgs } from "./args";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal extension-runner surface needed to resolve CLI flag values. The real
|
|
5
|
+
* `ExtensionRunner` satisfies this structurally; depending only on the surface
|
|
6
|
+
* keeps this module free of the heavier runner/session imports and unit-testable
|
|
7
|
+
* with a fake.
|
|
8
|
+
*/
|
|
9
|
+
export interface ExtensionFlagSink {
|
|
10
|
+
getFlags(): Map<string, { type: "boolean" | "string" }>;
|
|
11
|
+
setFlagValue(name: string, value: boolean | string): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve extension-registered CLI flags from `rawArgs` once the flag set is
|
|
16
|
+
* known, push the resolved values onto the sink, and return the parsed
|
|
17
|
+
* {@link Args} (whose `messages` and `fileArgs` now reflect those flags).
|
|
18
|
+
*
|
|
19
|
+
* The startup parse runs before extensions load, so it cannot recognise their
|
|
20
|
+
* flags: a string flag's value (`--spawn-peer reviewer` or `--spawn-peer=reviewer`)
|
|
21
|
+
* is otherwise left in `messages` and leaks into the initial prompt. Re-parsing
|
|
22
|
+
* here — through the *same* {@link parseArgs} the startup pass uses, now seeded
|
|
23
|
+
* with the registered flags — consumes every flag form (`--flag`, `--flag value`,
|
|
24
|
+
* `--flag=value`).
|
|
25
|
+
*
|
|
26
|
+
* {@link parseArgs} lets a registered flag shadow a same-named built-in, so even
|
|
27
|
+
* a built-in-colliding flag (e.g. plan-mode's boolean `--plan`, which would
|
|
28
|
+
* otherwise hit the built-in plan-model branch) is parsed with the extension's
|
|
29
|
+
* semantics and surfaces in `unknownFlags` — without consuming the following
|
|
30
|
+
* message or overwriting the built-in field. No built-in name list to maintain.
|
|
31
|
+
*
|
|
32
|
+
* Returns `null` when there is no sink or no registered extension flags, in
|
|
33
|
+
* which case the caller keeps its original startup parse (an extension-aware
|
|
34
|
+
* re-parse would be identical anyway).
|
|
35
|
+
*/
|
|
36
|
+
export function applyExtensionFlags(runner: ExtensionFlagSink | undefined, rawArgs: string[]): Args | null {
|
|
37
|
+
const extensionFlags = runner?.getFlags();
|
|
38
|
+
if (!runner || !extensionFlags || extensionFlags.size === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parsed = parseArgs(rawArgs, extensionFlags);
|
|
42
|
+
// `parseArgs` only records registered extension flags in `unknownFlags`, so
|
|
43
|
+
// every entry here is a flag this runner owns that was actually passed.
|
|
44
|
+
for (const [name, value] of parsed.unknownFlags) {
|
|
45
|
+
runner.setFlagValue(name, value);
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
package/src/cli/plugin-cli.ts
CHANGED
|
@@ -348,10 +348,12 @@ async function handleInstall(
|
|
|
348
348
|
flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
|
|
349
349
|
): Promise<void> {
|
|
350
350
|
if (packages.length === 0) {
|
|
351
|
-
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <
|
|
351
|
+
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <source>[features] ...`));
|
|
352
352
|
console.error(chalk.dim("Examples:"));
|
|
353
353
|
console.error(chalk.dim(` ${APP_NAME} plugin install @oh-my-pi/exa`));
|
|
354
354
|
console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
|
|
355
|
+
console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
|
|
356
|
+
console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
|
|
355
357
|
process.exit(1);
|
|
356
358
|
}
|
|
357
359
|
|
|
@@ -898,7 +900,7 @@ export function printPluginHelp(): void {
|
|
|
898
900
|
console.log(`${chalk.bold(`${APP_NAME} plugin`)} - Plugin lifecycle management
|
|
899
901
|
|
|
900
902
|
${chalk.bold("Commands:")}
|
|
901
|
-
install <
|
|
903
|
+
install <source>[features] Install plugins from npm, GitHub, or git URL
|
|
902
904
|
uninstall <pkg> Remove plugins
|
|
903
905
|
list Show installed plugins
|
|
904
906
|
link <path> Link local plugin for development
|
|
@@ -916,6 +918,12 @@ ${chalk.bold("Feature Syntax:")}
|
|
|
916
918
|
pkg[*] Install with all features
|
|
917
919
|
pkg[] Install with no optional features
|
|
918
920
|
|
|
921
|
+
${chalk.bold("Sources:")}
|
|
922
|
+
pkg, pkg@1.2.3 npm package (optionally pinned)
|
|
923
|
+
github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
|
|
924
|
+
https://github.com/user/repo Full git URL (https, ssh, or git protocol)
|
|
925
|
+
name@marketplace Marketplace plugin (see marketplace command)
|
|
926
|
+
|
|
919
927
|
${chalk.bold("Config Subcommands:")}
|
|
920
928
|
config list <pkg> List all settings
|
|
921
929
|
config get <pkg> <key> Get a setting value
|
|
@@ -938,5 +946,6 @@ ${chalk.bold("Examples:")}
|
|
|
938
946
|
${APP_NAME} plugin config set my-plugin apiKey sk-xxx
|
|
939
947
|
${APP_NAME} plugin doctor --fix
|
|
940
948
|
${APP_NAME} plugin install --scope project name@marketplace
|
|
949
|
+
${APP_NAME} plugin install github:user/repo#v1.0
|
|
941
950
|
`);
|
|
942
951
|
}
|