@oh-my-pi/pi-coding-agent 15.9.5 → 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 +35 -0
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +11 -1
- 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 +5 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- 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/interactive-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- 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/output-block.d.ts +11 -0
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/settings-schema.ts +11 -1
- 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 +28 -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/export/ttsr.ts +9 -0
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +6 -5
- 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 +1 -1
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +22 -1
- package/src/modes/components/copy-selector.ts +249 -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 +27 -13
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +26 -10
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/interactive-mode.ts +4 -4
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/copy-targets.ts +254 -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/session/agent-session.ts +6 -2
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/task/render.ts +38 -11
- 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 +5 -5
- package/src/tools/read.ts +7 -7
- package/src/tools/render-utils.ts +46 -0
- 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/output-block.ts +14 -0
- package/src/web/search/render.ts +3 -3
- 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
|
@@ -154,7 +154,7 @@ describe("shared eval executors", () => {
|
|
|
154
154
|
expect(result.output.trim()).toBe("42");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
it("treats idleTimeoutMs as
|
|
157
|
+
it("treats idleTimeoutMs as caller-owned watchdog metadata, not a fixed timer", async () => {
|
|
158
158
|
using tempDir = TempDir.createSync("@omp-eval-js-idle-budget-");
|
|
159
159
|
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
|
160
160
|
const sessionId = `js-idle-budget:${crypto.randomUUID()}`;
|
|
@@ -162,7 +162,7 @@ describe("shared eval executors", () => {
|
|
|
162
162
|
|
|
163
163
|
// With no wall-clock deadlineMs/timeoutMs and no aborting signal, a cell that
|
|
164
164
|
// runs well past idleTimeoutMs must still complete: the backend must never
|
|
165
|
-
// derive a competing fixed timer from the
|
|
165
|
+
// derive a competing fixed timer from the caller-owned watchdog budget.
|
|
166
166
|
const result = await executeJs("await Bun.sleep(120); return 'done';", {
|
|
167
167
|
sessionId,
|
|
168
168
|
session,
|
package/src/eval/agent-bridge.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { AgentOutputManager } from "../task/output-manager";
|
|
|
16
16
|
import type { AgentDefinition, AgentProgress } from "../task/types";
|
|
17
17
|
import type { ToolSession } from "../tools";
|
|
18
18
|
import { ToolError } from "../tools/tool-errors";
|
|
19
|
-
import {
|
|
19
|
+
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
|
20
20
|
import type { JsStatusEvent } from "./js/shared/types";
|
|
21
21
|
// Import review tools for side effects (registers subagent tool handlers).
|
|
22
22
|
import "../tools/review";
|
|
@@ -232,10 +232,9 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
|
|
232
232
|
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
|
|
233
233
|
const assignment = parsed.prompt.trim();
|
|
234
234
|
const context = trimToUndefined(parsed.context);
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
const result = await withBridgeHeartbeat(options.emitStatus, () =>
|
|
235
|
+
// Suspend eval timeout accounting while the subagent owns control. The
|
|
236
|
+
// timeout clock restarts once the bridge returns to the cell runtime.
|
|
237
|
+
const result = await withBridgeTimeoutPause(options.emitStatus, () =>
|
|
239
238
|
taskExecutor.runSubprocess({
|
|
240
239
|
cwd: options.session.cwd,
|
|
241
240
|
agent: effectiveAgent,
|
package/src/eval/backend.ts
CHANGED
|
@@ -10,12 +10,12 @@ export interface ExecutorBackendExecOptions {
|
|
|
10
10
|
signal?: AbortSignal;
|
|
11
11
|
session: ToolSession;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
* driven entirely by `signal`, which the eval tool arms as
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* and as cold-start headroom; they MUST
|
|
18
|
-
* timer from it.
|
|
13
|
+
* Runtime-work budget in milliseconds (the cell's `timeout`). Cancellation is
|
|
14
|
+
* driven entirely by `signal`, which the eval tool arms as a watchdog that
|
|
15
|
+
* pauses on bridge timeout-control status events and fires a `TimeoutError`
|
|
16
|
+
* reason only while the Python/JS runtime owns control. Backends use this
|
|
17
|
+
* value only for timeout-annotation text and as cold-start headroom; they MUST
|
|
18
|
+
* NOT derive a competing wall-clock timer from it.
|
|
19
19
|
*/
|
|
20
20
|
idleTimeoutMs: number;
|
|
21
21
|
reset: boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout suspension for in-flight host-side eval bridge calls.
|
|
3
|
+
*
|
|
4
|
+
* The eval watchdog caps a cell's `timeout` as a budget on the cell runtime's
|
|
5
|
+
* own work. Host-side `agent()` / `parallel()` / `llm()` bridge calls hand
|
|
6
|
+
* control to the outer TypeScript process, where the Python kernel or JS VM is
|
|
7
|
+
* only waiting for a result. While that delegated work is in flight, the cell
|
|
8
|
+
* timeout must be ignored completely; once the bridge returns and the runtime is
|
|
9
|
+
* back in control, the watchdog starts a fresh timeout window.
|
|
10
|
+
*
|
|
11
|
+
* Bridge helpers express that handoff with synthetic pause/resume status events
|
|
12
|
+
* on the existing `emitStatus → onStatus` path. Consumers MUST treat these as
|
|
13
|
+
* timeout-control events only: update the watchdog and drop them from rendered
|
|
14
|
+
* or persisted cell output.
|
|
15
|
+
*/
|
|
16
|
+
import type { JsStatusEvent } from "./js/shared/types";
|
|
17
|
+
|
|
18
|
+
/** Synthetic status op emitted when a bridge call leaves the cell runtime. */
|
|
19
|
+
export const EVAL_TIMEOUT_PAUSE_OP = "timeout-pause";
|
|
20
|
+
|
|
21
|
+
/** Synthetic status op emitted when a bridge call returns control to the runtime. */
|
|
22
|
+
export const EVAL_TIMEOUT_RESUME_OP = "timeout-resume";
|
|
23
|
+
|
|
24
|
+
/** Whether a status event is pure eval-timeout control and should not render. */
|
|
25
|
+
export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean {
|
|
26
|
+
return event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run {@link operation} while suspending the eval watchdog through
|
|
31
|
+
* {@link emitStatus}. A no-op wrapper when no status sink is wired.
|
|
32
|
+
*/
|
|
33
|
+
export async function withBridgeTimeoutPause<T>(
|
|
34
|
+
emitStatus: ((event: JsStatusEvent) => void) | undefined,
|
|
35
|
+
operation: () => Promise<T>,
|
|
36
|
+
): Promise<T> {
|
|
37
|
+
if (!emitStatus) return operation();
|
|
38
|
+
emitStatus({ op: EVAL_TIMEOUT_PAUSE_OP });
|
|
39
|
+
try {
|
|
40
|
+
return await operation();
|
|
41
|
+
} finally {
|
|
42
|
+
emitStatus({ op: EVAL_TIMEOUT_RESUME_OP });
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/eval/idle-timeout.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Watchdog for eval cell work.
|
|
3
3
|
*
|
|
4
|
-
* A cell's `timeout`
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
* long-running fanout that keeps reporting progress (e.g. `agent()` status
|
|
9
|
-
* updates, `log()`/`phase()`) never trips the timeout, while a genuinely
|
|
10
|
-
* stalled cell still gets interrupted.
|
|
4
|
+
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
|
|
5
|
+
* Host-side bridge calls can {@link pause} the watchdog so delegated
|
|
6
|
+
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
|
|
7
|
+
* starts a fresh timeout window once the runtime gets control back.
|
|
11
8
|
*
|
|
12
|
-
* The timer self-reschedules instead of being torn down
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* The active timer self-reschedules instead of being torn down on every
|
|
10
|
+
* activity event, so frequent activity costs one timestamp write per event.
|
|
11
|
+
* Pause is reference-counted because `parallel()` can have multiple bridge calls
|
|
12
|
+
* in flight at once.
|
|
15
13
|
*/
|
|
16
14
|
export class IdleTimeout {
|
|
17
15
|
readonly #controller = new AbortController();
|
|
@@ -20,6 +18,7 @@ export class IdleTimeout {
|
|
|
20
18
|
#deadlineMs: number;
|
|
21
19
|
#timer: NodeJS.Timeout | undefined;
|
|
22
20
|
#settled = false;
|
|
21
|
+
#pauseDepth = 0;
|
|
23
22
|
|
|
24
23
|
constructor(idleMs: number) {
|
|
25
24
|
this.#idleMs = Math.max(1, Math.floor(idleMs));
|
|
@@ -27,20 +26,39 @@ export class IdleTimeout {
|
|
|
27
26
|
this.#arm(this.#idleMs);
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/** Aborts with a `TimeoutError` reason once the
|
|
29
|
+
/** Aborts with a `TimeoutError` reason once the active timeout window is exhausted. */
|
|
31
30
|
get signal(): AbortSignal {
|
|
32
31
|
return this.#controller.signal;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
/** Configured
|
|
34
|
+
/** Configured active timeout window in milliseconds. */
|
|
36
35
|
get idleMs(): number {
|
|
37
36
|
return this.#idleMs;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
/** Record activity, pushing the
|
|
39
|
+
/** Record runtime activity, pushing the active deadline forward by `idleMs`. */
|
|
41
40
|
bump(): void {
|
|
41
|
+
if (this.#settled || this.#pauseDepth > 0) return;
|
|
42
|
+
this.#deadlineMs = Date.now() + this.#idleMs;
|
|
43
|
+
}
|
|
44
|
+
/** Suspend timeout accounting while control is delegated to host-side work. */
|
|
45
|
+
pause(): void {
|
|
42
46
|
if (this.#settled) return;
|
|
47
|
+
this.#pauseDepth++;
|
|
48
|
+
if (this.#pauseDepth !== 1) return;
|
|
49
|
+
if (this.#timer) {
|
|
50
|
+
clearTimeout(this.#timer);
|
|
51
|
+
this.#timer = undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resume timeout accounting with a fresh timeout window. */
|
|
56
|
+
resume(): void {
|
|
57
|
+
if (this.#settled || this.#pauseDepth === 0) return;
|
|
58
|
+
this.#pauseDepth--;
|
|
59
|
+
if (this.#pauseDepth > 0) return;
|
|
43
60
|
this.#deadlineMs = Date.now() + this.#idleMs;
|
|
61
|
+
this.#arm(this.#idleMs);
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
/** Stop the watchdog. Safe to call multiple times. */
|
|
@@ -65,7 +83,7 @@ export class IdleTimeout {
|
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
#onExpire(): void {
|
|
68
|
-
if (this.#settled) return;
|
|
86
|
+
if (this.#settled || this.#pauseDepth > 0) return;
|
|
69
87
|
const remainingMs = this.#deadlineMs - Date.now();
|
|
70
88
|
if (remainingMs > 0) {
|
|
71
89
|
// A bump moved the deadline forward after this timer was armed; wait
|
package/src/eval/js/executor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
|
|
2
2
|
import type { ToolSession } from "../../tools";
|
|
3
3
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
|
4
|
-
import {
|
|
4
|
+
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
|
|
5
5
|
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
|
|
6
6
|
import type { JsStatusEvent } from "./shared/types";
|
|
7
7
|
|
|
@@ -10,9 +10,9 @@ export interface JsExecutorOptions {
|
|
|
10
10
|
timeoutMs?: number;
|
|
11
11
|
deadlineMs?: number;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
* timeout-annotation text when the caller drives cancellation via
|
|
15
|
-
*
|
|
13
|
+
* Runtime-work budget (ms). Used for worker cold-start headroom and
|
|
14
|
+
* timeout-annotation text when the caller drives cancellation via the eval
|
|
15
|
+
* watchdog `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
|
|
16
16
|
*/
|
|
17
17
|
idleTimeoutMs?: number;
|
|
18
18
|
onChunk?: (chunk: string) => Promise<void> | void;
|
|
@@ -85,9 +85,9 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
|
|
85
85
|
options.signal && timeoutSignal
|
|
86
86
|
? AbortSignal.any([options.signal, timeoutSignal])
|
|
87
87
|
: (options.signal ?? timeoutSignal);
|
|
88
|
-
// The eval tool drives cancellation via
|
|
89
|
-
//
|
|
90
|
-
// derive a competing fixed timer from it.
|
|
88
|
+
// The eval tool drives cancellation via its own watchdog `signal` and passes
|
|
89
|
+
// only the runtime-work budget; use it solely as worker cold-start headroom
|
|
90
|
+
// and never derive a competing fixed timer from it.
|
|
91
91
|
const acquireBudgetMs = legacyTimeoutMs ?? options.idleTimeoutMs;
|
|
92
92
|
|
|
93
93
|
try {
|
|
@@ -105,10 +105,10 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
|
|
105
105
|
onText: chunk => outputSink.push(chunk),
|
|
106
106
|
onDisplay: output => {
|
|
107
107
|
if (output.type === "status") {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// Timeout-control events drive the eval watchdog only; never
|
|
109
|
+
// store or render them as cell output.
|
|
110
110
|
options.onStatus?.(output.event);
|
|
111
|
-
if (output.event
|
|
111
|
+
if (isEvalTimeoutControlEvent(output.event)) return;
|
|
112
112
|
}
|
|
113
113
|
displayOutputs.push(output);
|
|
114
114
|
},
|
package/src/eval/llm-bridge.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit
|
|
|
18
18
|
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
|
|
19
19
|
import type { ToolSession } from "../tools";
|
|
20
20
|
import { ToolError } from "../tools/tool-errors";
|
|
21
|
-
import {
|
|
21
|
+
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
|
22
22
|
import type { JsStatusEvent } from "./js/shared/types";
|
|
23
23
|
|
|
24
24
|
/** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
|
|
@@ -132,10 +132,9 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
|
|
132
132
|
|
|
133
133
|
const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
|
|
134
134
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const response = await withBridgeHeartbeat(options.emitStatus, () =>
|
|
135
|
+
// Suspend eval timeout accounting while the model request owns control. The
|
|
136
|
+
// timeout clock restarts once the bridge returns to the cell runtime.
|
|
137
|
+
const response = await withBridgeTimeoutPause(options.emitStatus, () =>
|
|
139
138
|
instrumentedCompleteSimple(
|
|
140
139
|
model,
|
|
141
140
|
{
|
package/src/eval/py/executor.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Settings } from "../../config/settings";
|
|
|
5
5
|
import { OutputSink } from "../../session/streaming-output";
|
|
6
6
|
import type { ToolSession } from "../../tools";
|
|
7
7
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
|
8
|
-
import {
|
|
8
|
+
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
|
|
9
9
|
import type { JsStatusEvent } from "../js/shared/types";
|
|
10
10
|
import {
|
|
11
11
|
checkPythonKernelAvailability,
|
|
@@ -27,8 +27,8 @@ export interface PythonExecutorOptions {
|
|
|
27
27
|
/** Absolute wall-clock deadline in milliseconds since epoch */
|
|
28
28
|
deadlineMs?: number;
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
* caller drives cancellation via
|
|
30
|
+
* Runtime-work budget (ms). Used only for timeout-annotation text when the
|
|
31
|
+
* caller drives cancellation via the eval watchdog `signal` instead of a
|
|
32
32
|
* wall-clock `deadlineMs`/`timeoutMs`. Does not arm a timer.
|
|
33
33
|
*/
|
|
34
34
|
idleTimeoutMs?: number;
|
|
@@ -492,10 +492,10 @@ async function executeWithKernel(
|
|
|
492
492
|
// long-running bridge helpers (e.g. `agent()`) surface progress mid-cell.
|
|
493
493
|
const collectDisplay = (output: KernelDisplayOutput) => {
|
|
494
494
|
if (output.type === "status") {
|
|
495
|
-
//
|
|
496
|
-
//
|
|
495
|
+
// Timeout-control events drive the eval watchdog only; never store or
|
|
496
|
+
// render them as cell output.
|
|
497
497
|
options?.onStatus?.(output.event);
|
|
498
|
-
if (output.event
|
|
498
|
+
if (isEvalTimeoutControlEvent(output.event)) return;
|
|
499
499
|
}
|
|
500
500
|
displayOutputs.push(output);
|
|
501
501
|
};
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
|
|
|
18
18
|
import { PYTHON_PRELUDE } from "./prelude";
|
|
19
19
|
import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
|
|
20
20
|
import { enumeratePythonRuntimes, filterEnv, type PythonRuntime, resolvePythonRuntime } from "./runtime";
|
|
21
|
+
import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
|
|
21
22
|
|
|
22
23
|
export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
|
|
23
24
|
export { renderKernelDisplay } from "./display";
|
|
@@ -253,7 +254,16 @@ export class PythonKernel {
|
|
|
253
254
|
stdin: "pipe",
|
|
254
255
|
stdout: "pipe",
|
|
255
256
|
stderr: "pipe",
|
|
256
|
-
|
|
257
|
+
// Detached from any inherited console only when the host itself
|
|
258
|
+
// has no console — kernel32!GetConsoleWindow() is authoritative
|
|
259
|
+
// (works even when every stdio stream is redirected), with a
|
|
260
|
+
// TTY-OR fallback when the FFI probe is unavailable. See #1960
|
|
261
|
+
// for the numpy/pandas LoadLibraryExW hang + SIGINT-recovery
|
|
262
|
+
// failure that motivates the predicate.
|
|
263
|
+
windowsHide: shouldHideKernelWindow({
|
|
264
|
+
platform: process.platform,
|
|
265
|
+
hostHasInheritableConsole: hostHasInheritableConsole(),
|
|
266
|
+
}),
|
|
257
267
|
});
|
|
258
268
|
kernel.#proc = proc;
|
|
259
269
|
kernel.#stdin = proc.stdin;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess spawn-option helpers for the Python kernel.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers (`shouldHideKernelWindow`, `consoleAttachedViaTTY`) live here
|
|
5
|
+
* so they can be unit-tested without dragging in the kernel's runtime
|
|
6
|
+
* dependencies. The effectful `hostHasInheritableConsole` wraps a Win32 FFI
|
|
7
|
+
* probe with a TTY fallback and is the function `kernel.ts` actually calls.
|
|
8
|
+
*/
|
|
9
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decide whether the long-lived Python kernel subprocess should be spawned
|
|
13
|
+
* with `windowsHide: true`.
|
|
14
|
+
*
|
|
15
|
+
* On Windows, Bun maps `windowsHide: true` to the `CREATE_NO_WINDOW` flag,
|
|
16
|
+
* which detaches the child from any inherited console. The Python kernel
|
|
17
|
+
* runs user code that imports NumPy/pandas; those native extensions
|
|
18
|
+
* (`numpy/_core/_multiarray_umath.pyd` + bundled OpenBLAS/SLEEF thread-pool
|
|
19
|
+
* init) can deadlock inside `LoadLibraryExW` when no console is attached,
|
|
20
|
+
* and a console-less child cannot receive SIGINT via
|
|
21
|
+
* `GenerateConsoleCtrlEvent` (the recovery path the host relies on). See
|
|
22
|
+
* issue #1960.
|
|
23
|
+
*
|
|
24
|
+
* So on Windows we hide only when the host itself has no console to share.
|
|
25
|
+
* In any launch where a console is attached — even one with every stdio
|
|
26
|
+
* stream redirected — the kernel inherits the parent's console, matching
|
|
27
|
+
* `python.exe` invoked from `cmd.exe`, which keeps native imports and
|
|
28
|
+
* SIGINT recovery working.
|
|
29
|
+
*
|
|
30
|
+
* Short-lived helper subprocesses elsewhere in the codebase (LSP probes,
|
|
31
|
+
* git, plugin installs) keep `windowsHide: true` because they don't load
|
|
32
|
+
* complex native modules and the brief console flash would be user-visible
|
|
33
|
+
* noise.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldHideKernelWindow(opts: {
|
|
36
|
+
platform: NodeJS.Platform;
|
|
37
|
+
hostHasInheritableConsole: boolean;
|
|
38
|
+
}): boolean {
|
|
39
|
+
if (opts.platform !== "win32") return false;
|
|
40
|
+
return !opts.hostHasInheritableConsole;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* TTY-based fallback used when the Win32 console probe is unavailable.
|
|
45
|
+
*
|
|
46
|
+
* Returns `true` if any of stdin/stdout/stderr is currently a TTY. This
|
|
47
|
+
* correctly detects the common interactive launches and the partial-
|
|
48
|
+
* redirection cases (`omp -p > out.txt`, `< in.txt`, `2> err.log`) where at
|
|
49
|
+
* least one stream stays bound to the terminal. The all-stdio-redirected
|
|
50
|
+
* case (`< in > out 2> err` from a console) is the reason we prefer the
|
|
51
|
+
* Win32 probe over this fallback whenever possible.
|
|
52
|
+
*/
|
|
53
|
+
export function consoleAttachedViaTTY(opts: {
|
|
54
|
+
stdinIsTTY: boolean;
|
|
55
|
+
stdoutIsTTY: boolean;
|
|
56
|
+
stderrIsTTY: boolean;
|
|
57
|
+
}): boolean {
|
|
58
|
+
return opts.stdinIsTTY || opts.stdoutIsTTY || opts.stderrIsTTY;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Probe `kernel32.dll!GetConsoleWindow()` to detect whether the current
|
|
63
|
+
* Windows process owns a console window.
|
|
64
|
+
*
|
|
65
|
+
* Returns `true` for a non-NULL HWND, `false` when NULL (no console — true
|
|
66
|
+
* service / `DETACHED_PROCESS` / GUI parent), and `null` when the probe
|
|
67
|
+
* itself fails (off-Windows, FFI disabled, or unexpected kernel32 layout).
|
|
68
|
+
* A `null` return means "don't trust me, use the TTY fallback".
|
|
69
|
+
*
|
|
70
|
+
* Cached on first call because in practice the console attachment of a
|
|
71
|
+
* long-lived OMP host never changes for the lifetime of the process, and
|
|
72
|
+
* we don't want to re-dlopen kernel32 on every kernel spawn.
|
|
73
|
+
*/
|
|
74
|
+
type ConsoleProbeResult = boolean | null;
|
|
75
|
+
let cachedWindowsConsoleProbe: { value: ConsoleProbeResult } | undefined;
|
|
76
|
+
|
|
77
|
+
function probeWindowsConsoleWindow(): ConsoleProbeResult {
|
|
78
|
+
if (cachedWindowsConsoleProbe) return cachedWindowsConsoleProbe.value;
|
|
79
|
+
let value: ConsoleProbeResult = null;
|
|
80
|
+
try {
|
|
81
|
+
const lib = dlopen("kernel32.dll", {
|
|
82
|
+
GetConsoleWindow: { args: [], returns: FFIType.ptr },
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const hwnd = lib.symbols.GetConsoleWindow();
|
|
86
|
+
// FFIType.ptr returns `Pointer | null`; a 0 pointer should also be
|
|
87
|
+
// treated as NULL defensively in case Bun ever returns 0n / 0.
|
|
88
|
+
value = hwnd !== null && hwnd !== 0;
|
|
89
|
+
} finally {
|
|
90
|
+
lib.close();
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
value = null;
|
|
94
|
+
}
|
|
95
|
+
cachedWindowsConsoleProbe = { value };
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reset the cached Win32 probe result. Test-only; not part of the public surface. */
|
|
100
|
+
export function __resetWindowsConsoleProbeCache(): void {
|
|
101
|
+
cachedWindowsConsoleProbe = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether the host process owns a console its children can inherit.
|
|
106
|
+
*
|
|
107
|
+
* - On Windows, the authoritative signal is `GetConsoleWindow()`. It returns
|
|
108
|
+
* a non-NULL HWND whenever the process has a console attached, regardless
|
|
109
|
+
* of how the standard streams are redirected — so an `omp -p ... < in.txt
|
|
110
|
+
* > out.txt 2> err.log` launched from a real Windows Terminal session is
|
|
111
|
+
* correctly classified as console-attached and the kernel keeps its
|
|
112
|
+
* inheritable console.
|
|
113
|
+
* - On any other platform, or if the FFI probe fails, fall back to the
|
|
114
|
+
* TTY-OR heuristic. That still catches the common interactive cases.
|
|
115
|
+
*/
|
|
116
|
+
export function hostHasInheritableConsole(): boolean {
|
|
117
|
+
if (process.platform === "win32") {
|
|
118
|
+
const native = probeWindowsConsoleWindow();
|
|
119
|
+
if (native !== null) return native;
|
|
120
|
+
}
|
|
121
|
+
return consoleAttachedViaTTY({
|
|
122
|
+
stdinIsTTY: !!process.stdin.isTTY,
|
|
123
|
+
stdoutIsTTY: !!process.stdout.isTTY,
|
|
124
|
+
stderrIsTTY: !!process.stderr.isTTY,
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/export/ttsr.ts
CHANGED
|
@@ -294,6 +294,9 @@ export class TtsrManager {
|
|
|
294
294
|
|
|
295
295
|
/** Add a TTSR rule to be monitored. */
|
|
296
296
|
addRule(rule: Rule): boolean {
|
|
297
|
+
if (!this.#settings.enabled) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
297
300
|
if (this.#rules.has(rule.name)) {
|
|
298
301
|
return false;
|
|
299
302
|
}
|
|
@@ -357,6 +360,9 @@ export class TtsrManager {
|
|
|
357
360
|
}
|
|
358
361
|
|
|
359
362
|
#matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
|
|
363
|
+
if (!this.#settings.enabled) {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
360
366
|
const matches: Rule[] = [];
|
|
361
367
|
for (const [name, entry] of this.#rules) {
|
|
362
368
|
if (!this.#canTrigger(name)) {
|
|
@@ -433,6 +439,9 @@ export class TtsrManager {
|
|
|
433
439
|
|
|
434
440
|
/** Check if any TTSR rules are registered. */
|
|
435
441
|
hasRules(): boolean {
|
|
442
|
+
if (!this.#settings.enabled) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
436
445
|
return this.#rules.size > 0;
|
|
437
446
|
}
|
|
438
447
|
|
|
@@ -354,6 +354,8 @@ export class ExtensionRunner {
|
|
|
354
354
|
"ctrl+o": true,
|
|
355
355
|
"ctrl+t": true,
|
|
356
356
|
"ctrl+g": true,
|
|
357
|
+
// Default chord for `app.message.followUp` (Windows Terminal can't deliver Ctrl+Enter; #1903).
|
|
358
|
+
"ctrl+q": true,
|
|
357
359
|
"shift+tab": true,
|
|
358
360
|
"shift+ctrl+p": true,
|
|
359
361
|
"alt+enter": true,
|