@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67
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 +74 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +24 -5
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/modes/components/assistant-message.d.ts +16 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -1
- package/dist/types/modes/types.d.ts +8 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- package/dist/types/tools/eval-render.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +25 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/hyperlink.d.ts +12 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +25 -5
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +100 -27
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/__tests__/shared-executors.test.ts +2 -2
- package/src/eval/agent-bridge.ts +4 -5
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/client.ts +80 -2
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/main.ts +8 -2
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +44 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/tool-execution.ts +71 -13
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +67 -12
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +52 -17
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +11 -2
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +17 -9
- package/src/session/agent-session.ts +43 -14
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/system-prompt.ts +4 -0
- package/src/task/render.ts +38 -11
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-render.ts +24 -9
- package/src/tools/eval.ts +14 -19
- package/src/tools/fetch.ts +34 -14
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +65 -19
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- package/src/tools/ssh.ts +21 -8
- package/src/tools/write.ts +17 -8
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/hyperlink.ts +42 -7
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +23 -55
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
|
@@ -119,7 +119,10 @@ export const KEYBINDINGS = {
|
|
|
119
119
|
description: "Open external editor",
|
|
120
120
|
},
|
|
121
121
|
"app.message.followUp": {
|
|
122
|
-
|
|
122
|
+
// Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
|
|
123
|
+
// but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
|
|
124
|
+
// first so the default binding works there without remapping (#1903).
|
|
125
|
+
defaultKeys: ["ctrl+q", "ctrl+enter"],
|
|
123
126
|
description: "Send follow-up message",
|
|
124
127
|
},
|
|
125
128
|
"app.message.dequeue": {
|
|
@@ -439,16 +442,51 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
|
|
|
439
442
|
loadKeybindingsConfig(readPath, writeBackPath);
|
|
440
443
|
}
|
|
441
444
|
|
|
445
|
+
const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
|
|
446
|
+
const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
|
|
447
|
+
|
|
448
|
+
function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
|
|
449
|
+
if (keys === undefined) return false;
|
|
450
|
+
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
451
|
+
for (const key of keyList) {
|
|
452
|
+
if (key.toLowerCase() === target) return true;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
|
|
458
|
+
for (const [keybinding, keys] of Object.entries(config)) {
|
|
459
|
+
if (!(keybinding in KEYBINDINGS)) continue;
|
|
460
|
+
if (keybinding === except) continue;
|
|
461
|
+
if (keyListIncludes(keys, target)) return true;
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
|
|
467
|
+
return keys.filter(key => key !== target);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
|
|
471
|
+
if (keys.length === 1) {
|
|
472
|
+
const key = keys[0];
|
|
473
|
+
if (key !== undefined) return key;
|
|
474
|
+
}
|
|
475
|
+
return [...keys];
|
|
476
|
+
}
|
|
477
|
+
|
|
442
478
|
/**
|
|
443
479
|
* Manages all keybindings (app + TUI).
|
|
444
480
|
* Extends the TUI KeybindingsManager with app-specific functionality.
|
|
445
481
|
*/
|
|
446
482
|
export class KeybindingsManager extends TuiKeybindingsManager {
|
|
447
483
|
#configPath: string | undefined;
|
|
484
|
+
#userBindings: KeybindingsConfig;
|
|
448
485
|
|
|
449
486
|
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
|
450
487
|
super(KEYBINDINGS, userBindings);
|
|
451
488
|
this.#configPath = configPath;
|
|
489
|
+
this.#userBindings = userBindings;
|
|
452
490
|
}
|
|
453
491
|
|
|
454
492
|
/**
|
|
@@ -480,6 +518,25 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
480
518
|
this.setUserBindings(config);
|
|
481
519
|
}
|
|
482
520
|
|
|
521
|
+
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
522
|
+
this.#userBindings = userBindings;
|
|
523
|
+
super.setUserBindings(userBindings);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
getKeys(keybinding: Keybinding): KeyId[] {
|
|
527
|
+
const keys = super.getKeys(keybinding);
|
|
528
|
+
if (keybinding !== FOLLOW_UP_KEYBINDING) return keys;
|
|
529
|
+
if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
|
|
530
|
+
if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) return keys;
|
|
531
|
+
return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getResolvedBindings(): KeybindingsConfig {
|
|
535
|
+
const resolved = super.getResolvedBindings();
|
|
536
|
+
resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
|
|
537
|
+
return resolved;
|
|
538
|
+
}
|
|
539
|
+
|
|
483
540
|
/**
|
|
484
541
|
* Get the effective resolved bindings (defaults + user overrides).
|
|
485
542
|
*/
|
|
@@ -38,6 +38,45 @@ const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
|
|
|
38
38
|
// "socket connection was closed unexpectedly").
|
|
39
39
|
const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
|
|
40
40
|
|
|
41
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
|
42
|
+
const OLLAMA_HOST_DEFAULT_PORT = "11434";
|
|
43
|
+
|
|
44
|
+
function normalizeOllamaHostEnv(value: string | undefined): string | undefined {
|
|
45
|
+
const trimmed = value?.trim();
|
|
46
|
+
if (!trimmed) return undefined;
|
|
47
|
+
const candidate = trimmed.includes("://")
|
|
48
|
+
? trimmed
|
|
49
|
+
: trimmed.startsWith("//")
|
|
50
|
+
? `http:${trimmed}`
|
|
51
|
+
: trimmed.startsWith(":")
|
|
52
|
+
? `http://127.0.0.1${trimmed}`
|
|
53
|
+
: `http://${trimmed}`;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(candidate);
|
|
56
|
+
if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (!parsed.port && parsed.protocol === "http:") {
|
|
60
|
+
parsed.port = OLLAMA_HOST_DEFAULT_PORT;
|
|
61
|
+
}
|
|
62
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getImplicitOllamaBaseUrl(): string {
|
|
69
|
+
const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim();
|
|
70
|
+
return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getOllamaContextLengthOverride(): number | undefined {
|
|
74
|
+
const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim();
|
|
75
|
+
if (!value) return undefined;
|
|
76
|
+
const parsed = Number(value);
|
|
77
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
41
80
|
// Anthropic-safe variant of the discovery cap. The Anthropic stream converter
|
|
42
81
|
// in `packages/ai/src/providers/anthropic.ts` derives the request limit as
|
|
43
82
|
// `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
|
|
@@ -1220,7 +1259,18 @@ export class ModelRegistry {
|
|
|
1220
1259
|
return models;
|
|
1221
1260
|
}
|
|
1222
1261
|
|
|
1223
|
-
|
|
1262
|
+
const contextLengthOverride = getOllamaContextLengthOverride();
|
|
1263
|
+
return models.map(model => {
|
|
1264
|
+
const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
|
|
1265
|
+
if (contextLengthOverride === undefined) {
|
|
1266
|
+
return normalized;
|
|
1267
|
+
}
|
|
1268
|
+
return {
|
|
1269
|
+
...normalized,
|
|
1270
|
+
contextWindow: contextLengthOverride,
|
|
1271
|
+
maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS),
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1224
1274
|
}
|
|
1225
1275
|
|
|
1226
1276
|
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
|
|
@@ -1229,7 +1279,7 @@ export class ModelRegistry {
|
|
|
1229
1279
|
this.#discoverableProviders.push({
|
|
1230
1280
|
provider: "ollama",
|
|
1231
1281
|
api: "openai-responses",
|
|
1232
|
-
baseUrl:
|
|
1282
|
+
baseUrl: getImplicitOllamaBaseUrl(),
|
|
1233
1283
|
discovery: { type: "ollama" },
|
|
1234
1284
|
optional: true,
|
|
1235
1285
|
});
|
|
@@ -1993,12 +2043,12 @@ export class ModelRegistry {
|
|
|
1993
2043
|
}
|
|
1994
2044
|
}
|
|
1995
2045
|
#normalizeOllamaBaseUrl(baseUrl?: string): string {
|
|
1996
|
-
const raw = baseUrl ||
|
|
2046
|
+
const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
|
|
1997
2047
|
try {
|
|
1998
2048
|
const parsed = new URL(raw);
|
|
1999
2049
|
return `${parsed.protocol}//${parsed.host}`;
|
|
2000
2050
|
} catch {
|
|
2001
|
-
return
|
|
2051
|
+
return DEFAULT_OLLAMA_BASE_URL;
|
|
2002
2052
|
}
|
|
2003
2053
|
}
|
|
2004
2054
|
|
|
@@ -635,7 +635,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
635
635
|
tab: "appearance",
|
|
636
636
|
label: "Terminal Hyperlinks",
|
|
637
637
|
description:
|
|
638
|
-
"Wrap
|
|
638
|
+
"Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
|
|
639
639
|
},
|
|
640
640
|
},
|
|
641
641
|
// Display rendering
|
|
@@ -722,6 +722,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
722
722
|
},
|
|
723
723
|
},
|
|
724
724
|
|
|
725
|
+
includeModelInPrompt: {
|
|
726
|
+
type: "boolean",
|
|
727
|
+
default: true,
|
|
728
|
+
ui: {
|
|
729
|
+
tab: "model",
|
|
730
|
+
label: "Include Model In Prompt",
|
|
731
|
+
description: "Surface the active model identifier in the system prompt so the agent knows which model it is",
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
|
|
725
735
|
// Sampling
|
|
726
736
|
temperature: {
|
|
727
737
|
type: "number",
|
|
@@ -892,6 +902,15 @@ export const SETTINGS_SCHEMA = {
|
|
|
892
902
|
"Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
|
|
893
903
|
},
|
|
894
904
|
},
|
|
905
|
+
"retry.modelFallback": {
|
|
906
|
+
type: "boolean",
|
|
907
|
+
default: true,
|
|
908
|
+
ui: {
|
|
909
|
+
tab: "model",
|
|
910
|
+
label: "Retry Model Fallback",
|
|
911
|
+
description: "Allow retry recovery to switch to configured fallback models",
|
|
912
|
+
},
|
|
913
|
+
},
|
|
895
914
|
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
|
|
896
915
|
"retry.fallbackRevertPolicy": {
|
|
897
916
|
type: "enum",
|
|
@@ -1845,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1845
1864
|
tab: "editing",
|
|
1846
1865
|
label: "Hash Lines",
|
|
1847
1866
|
description:
|
|
1848
|
-
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (
|
|
1867
|
+
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
|
|
1849
1868
|
},
|
|
1850
1869
|
},
|
|
1851
1870
|
|
|
@@ -2483,13 +2502,13 @@ export const SETTINGS_SCHEMA = {
|
|
|
2483
2502
|
// Tool Discovery
|
|
2484
2503
|
"tools.discoveryMode": {
|
|
2485
2504
|
type: "enum",
|
|
2486
|
-
values: ["off", "mcp-only", "all"] as const,
|
|
2487
|
-
default: "
|
|
2505
|
+
values: ["auto", "off", "mcp-only", "all"] as const,
|
|
2506
|
+
default: "auto",
|
|
2488
2507
|
ui: {
|
|
2489
2508
|
tab: "tools",
|
|
2490
2509
|
label: "Tool Discovery",
|
|
2491
2510
|
description:
|
|
2492
|
-
"Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
|
|
2511
|
+
"Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
|
|
2493
2512
|
},
|
|
2494
2513
|
},
|
|
2495
2514
|
|
|
@@ -3297,6 +3316,7 @@ export interface RetrySettings {
|
|
|
3297
3316
|
maxRetries: number;
|
|
3298
3317
|
baseDelayMs: number;
|
|
3299
3318
|
maxDelayMs: number;
|
|
3319
|
+
modelFallback: boolean;
|
|
3300
3320
|
}
|
|
3301
3321
|
|
|
3302
3322
|
export interface MemoriesSettings {
|
package/src/debug/raw-sse.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
matchesKey,
|
|
4
|
+
padding,
|
|
5
|
+
replaceTabs,
|
|
6
|
+
ScrollView,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@oh-my-pi/pi-tui";
|
|
2
10
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
3
11
|
import { theme } from "../modes/theme/theme";
|
|
4
12
|
import { copyToClipboard } from "../utils/clipboard";
|
|
@@ -146,14 +154,20 @@ export class RawSseViewerComponent implements Component {
|
|
|
146
154
|
const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
|
|
147
155
|
const bodyHeight = this.#bodyHeight();
|
|
148
156
|
const rawLines = this.#renderRawLines(innerWidth);
|
|
149
|
-
const
|
|
150
|
-
|
|
157
|
+
const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
|
|
158
|
+
height: bodyHeight,
|
|
159
|
+
scrollbar: "auto",
|
|
160
|
+
totalRows: rawLines.length,
|
|
161
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
162
|
+
});
|
|
163
|
+
sv.setScrollOffset(this.#scrollOffset);
|
|
164
|
+
const bodyRows = sv.render(innerWidth);
|
|
151
165
|
|
|
152
166
|
return [
|
|
153
167
|
this.#frameTop(innerWidth),
|
|
154
168
|
this.#frameLine(this.#summaryText(), innerWidth),
|
|
155
169
|
this.#frameSeparator(innerWidth),
|
|
156
|
-
...
|
|
170
|
+
...bodyRows.map(line => this.#frameLine(line, innerWidth)),
|
|
157
171
|
this.#frameLine(this.#statusText(), innerWidth),
|
|
158
172
|
this.#frameBottom(innerWidth),
|
|
159
173
|
];
|
|
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
|
|
|
14
14
|
/**
|
|
15
15
|
* Upper bound on the file size we snapshot. A section tag is a content hash of
|
|
16
16
|
* the *whole* file, so minting one means holding the full normalized text in
|
|
17
|
-
* the store. Files above this cap emit no
|
|
17
|
+
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
|
|
18
18
|
* editing of multi-megabyte files is out of scope under the full-content model.
|
|
19
19
|
*/
|
|
20
20
|
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
package/src/edit/index.ts
CHANGED
|
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
|
|
|
275
275
|
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
276
276
|
const input = typeof record.input === "string" ? record.input : undefined;
|
|
277
277
|
if (input) {
|
|
278
|
-
const hashlineMatch =
|
|
278
|
+
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
|
|
279
279
|
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
|
280
280
|
|
|
281
281
|
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
package/src/edit/renderer.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Edit tool renderer and LSP batching helpers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
|
|
5
|
+
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
|
6
6
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
8
8
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
@@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
function parseHashlineInputPreviewHeader(line: string): string | null {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const body =
|
|
331
|
+
const trimmed = line.trimEnd();
|
|
332
|
+
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
|
333
|
+
// Keep streaming previews tolerant while the closing bracket is still
|
|
334
|
+
// being generated; the parser enforces the final `[path#TAG]` shape.
|
|
335
|
+
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
|
|
336
|
+
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
|
|
337
337
|
const previewPath = normalizeHashlineInputPreviewPath(body);
|
|
338
338
|
return previewPath.length > 0 ? previewPath : null;
|
|
339
339
|
}
|
package/src/edit/streaming.ts
CHANGED
|
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
424
424
|
return previews.length > 0 ? previews : null;
|
|
425
425
|
},
|
|
426
426
|
renderStreamingFallback() {
|
|
427
|
-
// Never leak raw hashline syntax (`64:`, `|payload`,
|
|
427
|
+
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
|
|
428
428
|
// to the user — the streaming preview already projects every
|
|
429
429
|
// parseable op onto the real file via applyPartialTo, and an
|
|
430
430
|
// unparseable trailing chunk renders as "no preview yet" rather
|
|
@@ -10,7 +10,7 @@ import { AgentOutputManager } from "../../task/output-manager";
|
|
|
10
10
|
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
|
|
11
11
|
import type { ToolSession } from "../../tools";
|
|
12
12
|
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
|
|
13
|
-
import {
|
|
13
|
+
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
|
14
14
|
import { IdleTimeout } from "../idle-timeout";
|
|
15
15
|
import { disposeAllVmContexts } from "../js/context-manager";
|
|
16
16
|
import { executeJs } from "../js/executor";
|
|
@@ -236,7 +236,6 @@ describe("runEvalAgent", () => {
|
|
|
236
236
|
describe("agent() through eval runtimes", () => {
|
|
237
237
|
afterEach(() => {
|
|
238
238
|
vi.restoreAllMocks();
|
|
239
|
-
setBridgeHeartbeatIntervalMs();
|
|
240
239
|
});
|
|
241
240
|
|
|
242
241
|
afterAll(async () => {
|
|
@@ -397,6 +396,78 @@ describe("agent() through eval runtimes", () => {
|
|
|
397
396
|
expect(maxInFlight).toBeLessThanOrEqual(2);
|
|
398
397
|
});
|
|
399
398
|
|
|
399
|
+
it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
|
|
400
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-");
|
|
401
|
+
const settings = Settings.isolated({
|
|
402
|
+
"async.enabled": false,
|
|
403
|
+
"task.isolation.mode": "none",
|
|
404
|
+
"task.enableLsp": true,
|
|
405
|
+
"task.maxConcurrency": 6,
|
|
406
|
+
});
|
|
407
|
+
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
|
|
408
|
+
mockAgents();
|
|
409
|
+
// Subagents that ignore the abort for far longer than the kernel's SIGINT
|
|
410
|
+
// escalation window. Each kernel worker thread blocks in a synchronous
|
|
411
|
+
// `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
|
|
412
|
+
// The host must respond the instant the cell aborts so the kernel can
|
|
413
|
+
// unwind via KeyboardInterrupt instead of being hard-killed (which used to
|
|
414
|
+
// surface "[kernel] Python kernel shutdown" and lose all session state).
|
|
415
|
+
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
416
|
+
await Bun.sleep(9000); // deliberately ignores options.signal
|
|
417
|
+
return singleResult(options, { output: options.assignment ?? "" });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Seed persistent session state and confirm the kernel is reusable.
|
|
421
|
+
const seed = await executePython("PREP_MARKER = 4242", {
|
|
422
|
+
cwd: tempDir.path(),
|
|
423
|
+
sessionId,
|
|
424
|
+
sessionFile,
|
|
425
|
+
kernelMode: "session",
|
|
426
|
+
toolSession: session,
|
|
427
|
+
});
|
|
428
|
+
if (seed.exitCode === undefined && seed.cancelled) {
|
|
429
|
+
expect(seed.output).toBe("");
|
|
430
|
+
return; // kernel unavailable in this environment
|
|
431
|
+
}
|
|
432
|
+
expect(seed.exitCode).toBe(0);
|
|
433
|
+
|
|
434
|
+
const ac = new AbortController();
|
|
435
|
+
// Abort ~1s in, after the worker threads are blocked in their bridge calls.
|
|
436
|
+
setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
|
|
437
|
+
|
|
438
|
+
const start = Date.now();
|
|
439
|
+
const result = await executePython(
|
|
440
|
+
"import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
|
|
441
|
+
{
|
|
442
|
+
cwd: tempDir.path(),
|
|
443
|
+
sessionId,
|
|
444
|
+
sessionFile,
|
|
445
|
+
kernelMode: "session",
|
|
446
|
+
toolSession: session,
|
|
447
|
+
idleTimeoutMs: 60_000,
|
|
448
|
+
signal: ac.signal,
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
const elapsed = Date.now() - start;
|
|
452
|
+
|
|
453
|
+
// Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
|
|
454
|
+
// SIGINT escalation window rather than ~6s after it.
|
|
455
|
+
expect(result.cancelled).toBe(true);
|
|
456
|
+
expect(result.output).not.toContain("Python kernel shutdown");
|
|
457
|
+
expect(elapsed).toBeLessThan(4000);
|
|
458
|
+
|
|
459
|
+
// The persistent kernel survived the interrupt: prior state is intact.
|
|
460
|
+
const after = await executePython("print(PREP_MARKER)", {
|
|
461
|
+
cwd: tempDir.path(),
|
|
462
|
+
sessionId,
|
|
463
|
+
sessionFile,
|
|
464
|
+
kernelMode: "session",
|
|
465
|
+
toolSession: session,
|
|
466
|
+
});
|
|
467
|
+
expect(after.exitCode).toBe(0);
|
|
468
|
+
expect(after.output.trim()).toBe("4242");
|
|
469
|
+
}, 30_000);
|
|
470
|
+
|
|
400
471
|
it("streams enriched agent progress through onStatus before the cell finishes", async () => {
|
|
401
472
|
using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
|
|
402
473
|
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
|
|
@@ -488,24 +559,20 @@ describe("agent() through eval runtimes", () => {
|
|
|
488
559
|
expect(displayAgentEvents.length).toBe(2);
|
|
489
560
|
});
|
|
490
561
|
|
|
491
|
-
it("
|
|
492
|
-
using tempDir = TempDir.createSync("@omp-eval-agent-
|
|
493
|
-
const { session } = makeEvalSession(tempDir, "js-agent-
|
|
562
|
+
it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
|
|
563
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
|
|
564
|
+
const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
|
|
494
565
|
mockAgents();
|
|
495
|
-
// Heartbeat cadence well under the idle budget so a working-but-silent
|
|
496
|
-
// subagent re-arms the watchdog several times before it could expire.
|
|
497
|
-
setBridgeHeartbeatIntervalMs(15);
|
|
498
566
|
|
|
499
|
-
// runSubprocess runs far past the budget and emits NO progress
|
|
500
|
-
//
|
|
501
|
-
//
|
|
567
|
+
// runSubprocess runs far past the eval timeout budget and emits NO progress
|
|
568
|
+
// of its own. The bridge pause must make that delegated time invisible to
|
|
569
|
+
// the watchdog.
|
|
502
570
|
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
503
571
|
await Bun.sleep(200);
|
|
504
572
|
return singleResult(options, { output: "done" });
|
|
505
573
|
});
|
|
506
574
|
|
|
507
|
-
|
|
508
|
-
// ONLY a bridge heartbeat re-arms it.
|
|
575
|
+
const ops: string[] = [];
|
|
509
576
|
using idle = new IdleTimeout(60);
|
|
510
577
|
const result = await runEvalAgent(
|
|
511
578
|
{ prompt: "investigate" },
|
|
@@ -513,25 +580,29 @@ describe("agent() through eval runtimes", () => {
|
|
|
513
580
|
session,
|
|
514
581
|
signal: idle.signal,
|
|
515
582
|
emitStatus: event => {
|
|
516
|
-
|
|
583
|
+
ops.push(event.op);
|
|
584
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
|
585
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
|
517
586
|
},
|
|
518
587
|
},
|
|
519
588
|
);
|
|
520
589
|
|
|
521
|
-
expect(idle.signal.aborted).toBe(false);
|
|
522
590
|
expect(result.text).toBe("done");
|
|
591
|
+
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
592
|
+
expect(idle.signal.aborted).toBe(false);
|
|
593
|
+
|
|
594
|
+
await Bun.sleep(90);
|
|
595
|
+
expect(idle.signal.aborted).toBe(true);
|
|
523
596
|
});
|
|
524
597
|
|
|
525
|
-
it("
|
|
526
|
-
using tempDir = TempDir.createSync("@omp-eval-agent-progress-
|
|
527
|
-
const { session } = makeEvalSession(tempDir, "js-agent-progress-
|
|
598
|
+
it("keeps timeout paused despite agent() progress snapshots", async () => {
|
|
599
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
|
|
600
|
+
const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
|
|
528
601
|
mockAgents();
|
|
529
|
-
// Heartbeat slower than the budget: only the immediate beat at call start
|
|
530
|
-
// fires, so after the budget elapses nothing re-arms the watchdog.
|
|
531
|
-
setBridgeHeartbeatIntervalMs(10_000);
|
|
532
602
|
|
|
533
603
|
// Stream frequent progress snapshots (op:"agent") for well past the budget.
|
|
534
|
-
//
|
|
604
|
+
// They render as status, but timeout accounting is controlled only by the
|
|
605
|
+
// bridge pause/resume events.
|
|
535
606
|
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
536
607
|
for (let i = 0; i < 40; i++) {
|
|
537
608
|
options.onProgress?.({
|
|
@@ -557,21 +628,23 @@ describe("agent() through eval runtimes", () => {
|
|
|
557
628
|
|
|
558
629
|
const ops: string[] = [];
|
|
559
630
|
using idle = new IdleTimeout(80);
|
|
560
|
-
await runEvalAgent(
|
|
631
|
+
const result = await runEvalAgent(
|
|
561
632
|
{ prompt: "investigate" },
|
|
562
633
|
{
|
|
563
634
|
session,
|
|
564
635
|
signal: idle.signal,
|
|
565
636
|
emitStatus: event => {
|
|
566
637
|
ops.push(event.op);
|
|
567
|
-
if (event.op ===
|
|
638
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
|
639
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
|
568
640
|
},
|
|
569
641
|
},
|
|
570
642
|
);
|
|
571
643
|
|
|
572
|
-
|
|
573
|
-
|
|
644
|
+
expect(result.text).toBe("done");
|
|
645
|
+
expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
|
|
574
646
|
expect(ops).toContain("agent");
|
|
575
|
-
expect(
|
|
647
|
+
expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
|
|
648
|
+
expect(idle.signal.aborted).toBe(false);
|
|
576
649
|
});
|
|
577
650
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
EVAL_TIMEOUT_PAUSE_OP,
|
|
4
|
+
EVAL_TIMEOUT_RESUME_OP,
|
|
5
|
+
isEvalTimeoutControlEvent,
|
|
6
|
+
withBridgeTimeoutPause,
|
|
7
|
+
} from "../bridge-timeout";
|
|
8
|
+
import type { JsStatusEvent } from "../js/shared/types";
|
|
9
|
+
|
|
10
|
+
describe("withBridgeTimeoutPause", () => {
|
|
11
|
+
it("emits one pause before the operation and one resume after it settles", async () => {
|
|
12
|
+
const events: JsStatusEvent[] = [];
|
|
13
|
+
|
|
14
|
+
const value = await withBridgeTimeoutPause(
|
|
15
|
+
event => events.push(event),
|
|
16
|
+
async () => {
|
|
17
|
+
await Bun.sleep(80);
|
|
18
|
+
return "done";
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(value).toBe("done");
|
|
23
|
+
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
24
|
+
|
|
25
|
+
const settledCount = events.length;
|
|
26
|
+
await Bun.sleep(40);
|
|
27
|
+
expect(events.length).toBe(settledCount);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resumes timeout accounting even when the operation throws", async () => {
|
|
31
|
+
const events: JsStatusEvent[] = [];
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
withBridgeTimeoutPause(
|
|
35
|
+
event => events.push(event),
|
|
36
|
+
async () => {
|
|
37
|
+
await Bun.sleep(20);
|
|
38
|
+
throw new Error("boom");
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
).rejects.toThrow("boom");
|
|
42
|
+
|
|
43
|
+
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("runs the operation without emitting when no status sink is wired", async () => {
|
|
47
|
+
let ran = 0;
|
|
48
|
+
|
|
49
|
+
const value = await withBridgeTimeoutPause(undefined, async () => {
|
|
50
|
+
ran++;
|
|
51
|
+
await Bun.sleep(20);
|
|
52
|
+
return 42;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(value).toBe(42);
|
|
56
|
+
expect(ran).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("identifies timeout-control events as non-renderable status", () => {
|
|
60
|
+
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
|
|
61
|
+
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
|
|
62
|
+
expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -32,21 +32,34 @@ describe("IdleTimeout", () => {
|
|
|
32
32
|
expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it("
|
|
36
|
-
using idle = new IdleTimeout(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for (let i = 0; i < 6; i++) {
|
|
40
|
-
await Bun.sleep(40);
|
|
41
|
-
idle.bump();
|
|
42
|
-
}
|
|
35
|
+
it("ignores elapsed time while paused and resumes with a fresh window", async () => {
|
|
36
|
+
using idle = new IdleTimeout(80);
|
|
37
|
+
idle.pause();
|
|
38
|
+
await Bun.sleep(160);
|
|
43
39
|
expect(idle.signal.aborted).toBe(false);
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
const
|
|
41
|
+
idle.resume();
|
|
42
|
+
const firedEarly = await abortedWithin(idle.signal, 30);
|
|
43
|
+
expect(firedEarly).toBe(false);
|
|
44
|
+
const fired = await abortedWithin(idle.signal, 500);
|
|
47
45
|
expect(fired).toBe(true);
|
|
48
46
|
});
|
|
49
47
|
|
|
48
|
+
it("reference-counts overlapping pauses", async () => {
|
|
49
|
+
using idle = new IdleTimeout(60);
|
|
50
|
+
idle.pause();
|
|
51
|
+
idle.pause();
|
|
52
|
+
await Bun.sleep(120);
|
|
53
|
+
expect(idle.signal.aborted).toBe(false);
|
|
54
|
+
|
|
55
|
+
idle.resume();
|
|
56
|
+
await Bun.sleep(90);
|
|
57
|
+
expect(idle.signal.aborted).toBe(false);
|
|
58
|
+
|
|
59
|
+
idle.resume();
|
|
60
|
+
const fired = await abortedWithin(idle.signal, 500);
|
|
61
|
+
expect(fired).toBe(true);
|
|
62
|
+
});
|
|
50
63
|
it("never fires after dispose()", async () => {
|
|
51
64
|
const idle = new IdleTimeout(30);
|
|
52
65
|
idle.dispose();
|
|
@@ -55,12 +68,13 @@ describe("IdleTimeout", () => {
|
|
|
55
68
|
expect(idle.signal.aborted).toBe(false);
|
|
56
69
|
});
|
|
57
70
|
|
|
58
|
-
it("ignores
|
|
71
|
+
it("ignores pause/resume after the watchdog has already fired", async () => {
|
|
59
72
|
using idle = new IdleTimeout(30);
|
|
60
73
|
await abortedWithin(idle.signal, 500);
|
|
61
74
|
expect(idle.signal.aborted).toBe(true);
|
|
62
75
|
// Late activity must not un-abort or rearm a settled watchdog.
|
|
63
|
-
idle.
|
|
76
|
+
idle.pause();
|
|
77
|
+
idle.resume();
|
|
64
78
|
expect(idle.signal.aborted).toBe(true);
|
|
65
79
|
});
|
|
66
80
|
});
|