@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /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 an inactivity budget, not a fixed timer", async () => {
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 inactivity budget.
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,
@@ -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 { withBridgeHeartbeat } from "./heartbeat";
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
- // Pump a heartbeat while the subagent runs so the eval idle watchdog stays
236
- // armed across quiet stretches (time-to-first-token, long nested tools)
237
- // where `onProgress` would otherwise emit no status to re-arm it.
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,
@@ -10,12 +10,12 @@ export interface ExecutorBackendExecOptions {
10
10
  signal?: AbortSignal;
11
11
  session: ToolSession;
12
12
  /**
13
- * Inactivity budget in milliseconds (the cell's `timeout`). Cancellation is
14
- * driven entirely by `signal`, which the eval tool arms as an idle watchdog
15
- * that fires a `TimeoutError` reason after this much time with no progress
16
- * (status) events. Backends use this value only for timeout-annotation text
17
- * and as cold-start headroom; they MUST NOT derive a competing wall-clock
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
+ }
@@ -1,17 +1,15 @@
1
1
  /**
2
- * Inactivity watchdog for eval cells.
2
+ * Watchdog for eval cell work.
3
3
  *
4
- * A cell's `timeout` is treated as an *idle* budget rather than a hard
5
- * wall-clock deadline: the watchdog aborts {@link signal} (with a
6
- * `TimeoutError` reason, matching `AbortSignal.timeout`) only once `idleMs`
7
- * elapses with no {@link bump}. Every progress signal re-arms it, so a
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 and recreated on every
13
- * bump, so a high-frequency stream of bumps (sub-second agent progress) costs
14
- * one timestamp write per event rather than churning a timer each time.
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 inactivity budget is exhausted. */
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 inactivity budget in milliseconds. */
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 inactivity deadline forward by `idleMs`. */
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
@@ -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 { EVAL_HEARTBEAT_OP } from "../heartbeat";
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
- * Inactivity budget (ms). Used for worker cold-start headroom and
14
- * timeout-annotation text when the caller drives cancellation via an
15
- * idle-aware `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
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 an idle-aware `signal` and passes only
89
- // an inactivity budget; use it solely as worker cold-start headroom and never
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
- // Heartbeats are pure idle-watchdog keepalives: forward them so
109
- // the eval tool re-arms its timer, but never store or render them.
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.op === EVAL_HEARTBEAT_OP) return;
111
+ if (isEvalTimeoutControlEvent(output.event)) return;
112
112
  }
113
113
  displayOutputs.push(output);
114
114
  },
@@ -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 { withBridgeHeartbeat } from "./heartbeat";
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
- // A oneshot completion emits no status until it returns, so pump a heartbeat
136
- // while it runs to keep the eval idle watchdog armed across a slow (e.g.
137
- // reasoning-tier) request that would otherwise look like a stalled cell.
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
  {
@@ -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 { EVAL_HEARTBEAT_OP } from "../heartbeat";
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
- * Inactivity budget (ms). Used only for timeout-annotation text when the
31
- * caller drives cancellation via an idle-aware `signal` instead of a
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
- // Heartbeats are pure idle-watchdog keepalives: forward them so the
496
- // eval tool re-arms its timer, but never store or render them.
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.op === EVAL_HEARTBEAT_OP) return;
498
+ if (isEvalTimeoutControlEvent(output.event)) return;
499
499
  }
500
500
  displayOutputs.push(output);
501
501
  };
@@ -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
- windowsHide: true,
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
+ }
@@ -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,