@gajae-code/coding-agent 0.13.0 → 0.13.2

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 (119) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  35. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  36. package/dist/types/task/executor.d.ts +4 -7
  37. package/dist/types/tools/index.d.ts +1 -1
  38. package/dist/types/tools/read.d.ts +1 -1
  39. package/dist/types/tools/search.d.ts +1 -1
  40. package/dist/types/utils/edit-mode.d.ts +69 -0
  41. package/package.json +8 -8
  42. package/scripts/generate-hotkeys-docs.ts +8 -6
  43. package/src/commands/sdk.ts +23 -2
  44. package/src/config/file-lock.ts +25 -5
  45. package/src/config/keybindings.ts +4 -2
  46. package/src/config/model-registry.ts +20 -0
  47. package/src/config/models-config-schema.ts +1 -0
  48. package/src/config/settings-schema.ts +29 -22
  49. package/src/config/settings.ts +42 -10
  50. package/src/coordinator-mcp/server.ts +87 -106
  51. package/src/edit/index.ts +5 -19
  52. package/src/edit/renderer.ts +3 -2
  53. package/src/edit/streaming.ts +28 -15
  54. package/src/eval/py/executor.ts +108 -10
  55. package/src/eval/py/kernel.ts +86 -12
  56. package/src/eval/py/runtime.ts +200 -16
  57. package/src/exec/bash-executor.ts +36 -37
  58. package/src/gjc-runtime/gc-runtime.ts +25 -3
  59. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  60. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  61. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  62. package/src/internal-urls/docs-index.generated.ts +9 -9
  63. package/src/modes/acp/acp-agent.ts +10 -2
  64. package/src/modes/components/model-selector.ts +28 -8
  65. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  66. package/src/modes/controllers/selector-controller.ts +4 -2
  67. package/src/modes/interactive-mode.ts +4 -0
  68. package/src/runtime/process-lifecycle.ts +126 -27
  69. package/src/runtime-mcp/transports/stdio.ts +11 -6
  70. package/src/sdk/acp/adapter.ts +18 -10
  71. package/src/sdk/broker/broker.ts +55 -4
  72. package/src/sdk/broker/ensure.ts +10 -7
  73. package/src/sdk/broker/identity.ts +10 -0
  74. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  75. package/src/sdk/broker/lifecycle.ts +53 -10
  76. package/src/sdk/broker/process-incarnation.ts +6 -1
  77. package/src/sdk/broker/session-index.ts +28 -11
  78. package/src/sdk/broker/startup-failure.ts +101 -0
  79. package/src/sdk/broker/transport.ts +1 -0
  80. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  81. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  82. package/src/sdk/bus/conversation-store.ts +171 -50
  83. package/src/sdk/bus/index.ts +101 -47
  84. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  85. package/src/sdk/bus/notification-service.ts +3 -3
  86. package/src/sdk/bus/reconciliation-store.ts +16 -0
  87. package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
  88. package/src/sdk/bus/telegram-daemon.ts +151 -41
  89. package/src/sdk/bus/topic-registry.ts +38 -9
  90. package/src/sdk/cli/session-cli.ts +20 -7
  91. package/src/sdk/client/discovery.ts +23 -2
  92. package/src/sdk/host/query/cursor.ts +4 -0
  93. package/src/sdk/host/query/handlers.ts +99 -100
  94. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  95. package/src/sdk/host/session-runtime.test.ts +98 -1
  96. package/src/sdk/host/session-runtime.ts +57 -15
  97. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  98. package/src/sdk/host/websocket-transport.ts +54 -30
  99. package/src/sdk/index.ts +1 -0
  100. package/src/sdk/mcp/server.ts +19 -6
  101. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  102. package/src/sdk/protocol/operation-registry.ts +8 -12
  103. package/src/sdk/session.ts +5 -14
  104. package/src/sdk/turn-result.ts +53 -0
  105. package/src/session/agent-session.ts +112 -69
  106. package/src/session/blob-store.ts +91 -21
  107. package/src/session/internal/managed-session-storage.ts +136 -21
  108. package/src/session/session-manager.ts +408 -81
  109. package/src/session/session-storage.ts +68 -10
  110. package/src/slash-commands/builtin-registry.ts +43 -5
  111. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  112. package/src/task/executor.ts +25 -8
  113. package/src/task/index.ts +5 -4
  114. package/src/tools/bash.ts +11 -9
  115. package/src/tools/index.ts +1 -1
  116. package/src/tools/read.ts +12 -11
  117. package/src/tools/search.ts +7 -7
  118. package/src/utils/edit-mode.ts +180 -9
  119. package/src/web/search/providers/insane.ts +55 -1
package/src/edit/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
2
- import { $pickenv, prompt } from "@gajae-code/utils";
2
+ import { prompt } from "@gajae-code/utils";
3
3
  import type * as z from "zod/v4";
4
4
  import {
5
5
  executeHashlineSingle,
@@ -22,7 +22,7 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
22
22
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
23
23
  import type { ToolSession } from "../tools";
24
24
  import { VimTool, vimSchema } from "../tools/vim";
25
- import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
25
+ import { type EditMode, resolveEditMode, resolveForcedEnvEditMode } from "../utils/edit-mode";
26
26
  import type { VimToolDetails } from "../vim/types";
27
27
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
28
28
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
@@ -132,19 +132,6 @@ type EditModeDefinition = {
132
132
  ) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
133
133
  };
134
134
 
135
- function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
136
- if (!rawEditMode || rawEditMode === "auto") {
137
- return undefined;
138
- }
139
-
140
- const editMode = normalizeEditMode(rawEditMode);
141
- if (!editMode) {
142
- throw new Error(`Invalid PI_EDIT_VARIANT: ${rawEditMode}`);
143
- }
144
-
145
- return editMode;
146
- }
147
-
148
135
  function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
149
136
  switch (rawValue) {
150
137
  case "true":
@@ -347,15 +334,15 @@ export class EditTool implements AgentTool<TInput> {
347
334
  readonly #allowFuzzy: boolean;
348
335
  readonly #fuzzyThreshold: number;
349
336
  readonly #writethrough: WritethroughCallback;
350
- readonly #editMode?: EditMode;
351
337
  readonly #vimTool: VimTool;
352
338
  readonly #pendingDeferredFetches = new Map<string, AbortController>();
353
339
 
354
340
  constructor(private readonly session: ToolSession) {
355
341
  const { PI_EDIT_FUZZY: editFuzzy = "auto", PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto" } = Bun.env;
356
- const envEditVariant = $pickenv("GJC_EDIT_VARIANT", "PI_EDIT_VARIANT") ?? "auto";
357
342
 
358
- this.#editMode = resolveConfiguredEditMode(envEditVariant);
343
+ // Fail fast on an invalid forced env variant; the shared resolver owns
344
+ // env precedence at resolution time (no separate cache here).
345
+ resolveForcedEnvEditMode();
359
346
  this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
360
347
  this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
361
348
  this.#writethrough = createEditWritethrough(session);
@@ -363,7 +350,6 @@ export class EditTool implements AgentTool<TInput> {
363
350
  }
364
351
 
365
352
  get mode(): EditMode {
366
- if (this.#editMode) return this.#editMode;
367
353
  return resolveEditMode(this.session);
368
354
  }
369
355
 
@@ -356,8 +356,8 @@ export const editToolRenderer = {
356
356
  });
357
357
  const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
358
358
  const rawPath = inventory.paths[0] ?? "";
359
- const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move;
360
- const op = editArgs.op || firstEdit?.op || inventory.firstOp;
359
+ const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || inventory.rename;
360
+ const op = editArgs.op || firstEdit?.op || inventory.op;
361
361
  const { description } = formatEditDescription(rawPath, uiTheme, options.expanded ? { rename } : undefined);
362
362
  const spinner =
363
363
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
@@ -416,6 +416,7 @@ function resolveCompletedEditIdentity(
416
416
  const firstEdit = args?.edits?.[0];
417
417
  const inventory = getEditRequestTargetInventory(args, editMode, { isPartial });
418
418
  const detailsPath = details && "path" in details ? details.path : undefined;
419
+
419
420
  return {
420
421
  path: detailsPath ?? args?.file_path ?? args?.path ?? firstEdit?.path ?? inventory.paths[0] ?? "",
421
422
  op: details?.op ?? args?.op ?? firstEdit?.op,
@@ -31,7 +31,7 @@ import { replaceTabs, truncateToWidth } from "../tools/render-utils";
31
31
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
32
32
  import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
33
33
  import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
34
- import { computePatchDiff, type Operation, type PatchEditEntry } from "./modes/patch";
34
+ import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
35
35
  import type { ReplaceEditEntry } from "./modes/replace";
36
36
 
37
37
  export interface PerFileDiffPreview {
@@ -76,11 +76,11 @@ export interface EditStreamingStrategy<Args = unknown> {
76
76
  }
77
77
  export interface EditRequestTargetInventory {
78
78
  paths: string[];
79
- /** Operation of the first target (Create/Delete) when derivable from the
80
- * request payload (apply_patch/hashline free-form envelopes carry op on the
81
- * parsed entry, not on the top-level args). */
82
- firstOp?: Operation;
83
79
  parseError?: string;
80
+ /** First envelope entry operation, for free-form modes that carry no structured `edits`. */
81
+ op?: PatchEditEntry["op"];
82
+ /** First envelope entry rename target, for free-form modes that carry no structured `edits`. */
83
+ rename?: string;
84
84
  }
85
85
  const MISSING_APPLY_PATCH_END_ERROR = `The last line of the patch must be '${END_PATCH_MARKER}'`;
86
86
 
@@ -135,6 +135,19 @@ function getHashlineTargetPaths(input: string): string[] {
135
135
  return paths.filter(Boolean);
136
136
  }
137
137
 
138
+ /**
139
+ * `apply_patch` carries operation metadata inside the envelope rather than in structured `edits`,
140
+ * so the collapsed card has to read op/rename off the first parsed entry to stay accurate while live.
141
+ */
142
+ function applyPatchEntryInventory(entries: readonly ApplyPatchEntry[]): EditRequestTargetInventory {
143
+ const first = entries[0];
144
+ return {
145
+ paths: orderedDistinctPaths(entries.map(entry => entry.path)),
146
+ op: first?.op,
147
+ rename: first?.rename,
148
+ };
149
+ }
150
+
138
151
  export function getEditRequestTargetInventory(
139
152
  args: unknown,
140
153
  editMode: EditMode | undefined,
@@ -154,22 +167,22 @@ export function getEditRequestTargetInventory(
154
167
  if (editMode === "hashline") {
155
168
  const input = typeof values.input === "string" ? values.input : "";
156
169
  const headerPaths = getHashlineTargetPaths(input);
157
- return { paths: orderedDistinctPaths(headerPaths.length > 0 ? headerPaths : [topLevelPath]) };
170
+ // `path` is only a fallback for headerless input; explicit §PATH headers are the full target set.
171
+ if (headerPaths.length > 0) return { paths: orderedDistinctPaths(headerPaths) };
172
+ return { paths: orderedDistinctPaths([topLevelPath]) };
158
173
  }
159
174
 
160
- if (editMode === "apply_patch") {
175
+ // Legacy call sites render the shared edit card without threading `renderContext.editMode`;
176
+ // a free-form `input` string is only ever an apply_patch envelope there.
177
+ if (editMode === "apply_patch" || (editMode === undefined && typeof values.input === "string")) {
161
178
  const input = typeof values.input === "string" ? values.input : "";
162
179
  try {
163
- const entries = expandApplyPatchToEntries({ input });
164
- return { paths: orderedDistinctPaths(entries.map(entry => entry.path)), firstOp: entries[0]?.op };
180
+ return applyPatchEntryInventory(expandApplyPatchToEntries({ input }));
165
181
  } catch (err) {
166
182
  const parseError = err instanceof Error ? err.message : String(err);
167
- const previewEntries = expandApplyPatchToPreviewEntries({ input });
168
- const paths = orderedDistinctPaths(previewEntries.map(entry => entry.path));
169
- if (options.isPartial && parseError === MISSING_APPLY_PATCH_END_ERROR) {
170
- return { paths, firstOp: previewEntries[0]?.op };
171
- }
172
- return { paths, firstOp: previewEntries[0]?.op, parseError };
183
+ const inventory = applyPatchEntryInventory(expandApplyPatchToPreviewEntries({ input }));
184
+ if (options.isPartial && parseError === MISSING_APPLY_PATCH_END_ERROR) return inventory;
185
+ return { ...inventory, parseError };
173
186
  }
174
187
  }
175
188
 
@@ -116,6 +116,7 @@ interface PythonSession {
116
116
  interface InitializingPythonSession {
117
117
  sessionId: string;
118
118
  promise: Promise<PythonSession>;
119
+ cancelled?: PythonExecutionCancelledError;
119
120
  }
120
121
 
121
122
  let pythonResourceCleanupRegistered = false;
@@ -126,6 +127,14 @@ function ensurePythonResourceCleanup(): void {
126
127
  registerResourceOwner("python-kernel-sessions", disposeAllKernelSessions);
127
128
  }
128
129
  const sessions = new Map<string, PythonSession | InitializingPythonSession>();
130
+ const retiringKernels = new Set<PythonKernel>();
131
+
132
+ async function shutdownOrRetainKernel(kernel: PythonKernel): Promise<void> {
133
+ const result = await kernel.shutdown().catch(() => undefined);
134
+ if (result?.confirmed) return;
135
+ retiringKernels.add(kernel);
136
+ logger.warn("Python kernel shutdown not confirmed", { kernelId: kernel.id });
137
+ }
129
138
 
130
139
  function isInitializingSession(
131
140
  session: PythonSession | InitializingPythonSession,
@@ -312,9 +321,23 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
312
321
  async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
313
322
  const existing = sessions.get(sessionId);
314
323
  if (existing) {
315
- const session = isInitializingSession(existing)
316
- ? await waitForPromiseWithCancellation(existing.promise, options)
317
- : existing;
324
+ let session: PythonSession;
325
+ if (isInitializingSession(existing)) {
326
+ try {
327
+ session = await waitForPromiseWithCancellation(existing.promise, options);
328
+ } catch (error) {
329
+ const remainingMs = getRemainingTimeoutMs(options.deadlineMs);
330
+ if (
331
+ isCancellationError(error) &&
332
+ !options.signal?.aborted &&
333
+ (remainingMs === undefined || remainingMs > 0)
334
+ )
335
+ return await acquireSession(sessionId, cwd, options);
336
+ throw error;
337
+ }
338
+ } else {
339
+ session = existing;
340
+ }
318
341
  attachOwner(session, sessionId, options.kernelOwnerId);
319
342
  ensurePythonResourceCleanup();
320
343
  return session;
@@ -324,9 +347,13 @@ async function acquireSession(sessionId: string, cwd: string, options: PythonExe
324
347
  sessionId,
325
348
  promise: Promise.resolve().then(async () => {
326
349
  const kernel = await startKernel(cwd, options);
350
+ if (initializing.cancelled) {
351
+ await shutdownOrRetainKernel(kernel);
352
+ throw initializing.cancelled;
353
+ }
327
354
  const current = sessions.get(sessionId);
328
355
  if (current !== initializing) {
329
- await kernel.shutdown().catch(() => undefined);
356
+ await shutdownOrRetainKernel(kernel);
330
357
  const winner = current
331
358
  ? isInitializingSession(current)
332
359
  ? await waitForPromiseWithCancellation(current.promise, options)
@@ -348,13 +375,49 @@ async function acquireSession(sessionId: string, cwd: string, options: PythonExe
348
375
  }),
349
376
  };
350
377
  sessions.set(sessionId, initializing);
378
+ ensurePythonResourceCleanup();
379
+ let cancellationTimer: NodeJS.Timeout | undefined;
380
+ const retireCancelledInitialization = (timedOut: boolean): void => {
381
+ if (initializing.cancelled) return;
382
+ initializing.cancelled = new PythonExecutionCancelledError(timedOut);
383
+ if (sessions.get(sessionId) === initializing) sessions.delete(sessionId);
384
+ };
385
+ const onAbort = (): void => {
386
+ options.signal?.removeEventListener("abort", onAbort);
387
+ retireCancelledInitialization(isTimedOutCancellation(options.signal?.reason, options.signal));
388
+ };
389
+ if (options.signal?.aborted) {
390
+ onAbort();
391
+ } else if (options.signal) {
392
+ options.signal.addEventListener("abort", onAbort, { once: true });
393
+ }
394
+ const remainingMs = getRemainingTimeoutMs(options.deadlineMs);
395
+ if (remainingMs !== undefined) {
396
+ if (remainingMs <= 0) {
397
+ retireCancelledInitialization(true);
398
+ } else {
399
+ cancellationTimer = setTimeout(() => retireCancelledInitialization(true), remainingMs);
400
+ cancellationTimer.unref();
401
+ }
402
+ }
403
+ void initializing.promise
404
+ .finally(() => {
405
+ options.signal?.removeEventListener("abort", onAbort);
406
+ if (cancellationTimer) clearTimeout(cancellationTimer);
407
+ })
408
+ .catch(() => undefined);
351
409
  try {
352
410
  const session = await waitForPromiseWithCancellation(initializing.promise, options);
353
411
  attachOwner(session, sessionId, options.kernelOwnerId);
354
412
  ensurePythonResourceCleanup();
355
413
  return session;
356
414
  } catch (err) {
357
- if (sessions.get(sessionId) === initializing) sessions.delete(sessionId);
415
+ if (isCancellationError(err)) {
416
+ retireCancelledInitialization(isTimedOutCancellation(err, options.signal));
417
+ await initializing.promise.catch(() => undefined);
418
+ } else if (sessions.get(sessionId) === initializing) {
419
+ sessions.delete(sessionId);
420
+ }
358
421
  throw err;
359
422
  }
360
423
  }
@@ -429,6 +492,8 @@ async function runQueued<T>(
429
492
 
430
493
  export async function disposeAllKernelSessions(): Promise<void> {
431
494
  const all = [...sessions.entries()];
495
+ const retiring = [...retiringKernels];
496
+ retiringKernels.clear();
432
497
  for (const [id, session] of all) {
433
498
  if (sessions.get(id) === session) sessions.delete(id);
434
499
  }
@@ -450,6 +515,16 @@ export async function disposeAllKernelSessions(): Promise<void> {
450
515
  logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
451
516
  if (!sessions.has(id)) sessions.set(id, session);
452
517
  }
518
+ const retiringResults = await Promise.allSettled(retiring.map(kernel => kernel.shutdown()));
519
+ for (let i = 0; i < retiring.length; i += 1) {
520
+ const result = retiringResults[i];
521
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
522
+ retiringKernels.add(retiring[i]);
523
+ logger.warn("Python kernel shutdown not confirmed", {
524
+ kernelId: retiring[i].id,
525
+ reason: result.status === "rejected" ? result.reason : "not confirmed",
526
+ });
527
+ }
453
528
  }
454
529
 
455
530
  export async function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
@@ -596,10 +671,10 @@ async function executeWithKernel(
596
671
  }
597
672
 
598
673
  async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions): Promise<void> {
599
- const availability = await waitForPromiseWithCancellation(
600
- checkPythonKernelAvailability(cwd, options.runtimeOptions),
601
- options,
602
- );
674
+ const availability = await checkPythonKernelAvailability(cwd, options.runtimeOptions, {
675
+ signal: options.signal,
676
+ deadlineMs: options.deadlineMs,
677
+ });
603
678
  if (!availability.ok) {
604
679
  throw new Error(availability.reason ?? "Python kernel unavailable");
605
680
  }
@@ -683,11 +758,28 @@ export async function executePythonWithKernel(
683
758
  export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
684
759
  const cwd = options?.cwd ?? getProjectDir();
685
760
  const deadlineMs = getExecutionDeadlineMs(options);
761
+ const deadlineController = deadlineMs === undefined ? undefined : new AbortController();
762
+ const combinedController = deadlineController && options?.signal ? new AbortController() : undefined;
763
+ const forwardAbort = (): void => combinedController?.abort(options?.signal?.reason);
764
+ if (combinedController) {
765
+ if (options?.signal?.aborted) forwardAbort();
766
+ else options?.signal?.addEventListener("abort", forwardAbort, { once: true });
767
+ }
768
+ const forwardDeadlineAbort = (): void => combinedController?.abort(deadlineController?.signal.reason);
769
+ if (combinedController && deadlineController)
770
+ deadlineController.signal.addEventListener("abort", forwardDeadlineAbort, { once: true });
771
+ const signal = combinedController?.signal ?? deadlineController?.signal ?? options?.signal;
772
+ const remainingMs = getRemainingTimeoutMs(deadlineMs);
773
+ const deadlineTimer =
774
+ deadlineController && remainingMs !== undefined
775
+ ? setTimeout(() => deadlineController.abort(new PythonExecutionCancelledError(true)), Math.max(0, remainingMs))
776
+ : undefined;
777
+ deadlineTimer?.unref();
686
778
  const executionOptions: PythonExecutorOptions = {
687
779
  ...(options ?? {}),
780
+ signal,
688
781
  deadlineMs,
689
782
  };
690
-
691
783
  try {
692
784
  requireRemainingTimeoutMs(deadlineMs);
693
785
  if (executionOptions.signal?.aborted) {
@@ -708,5 +800,11 @@ export async function executePython(code: string, options?: PythonExecutorOption
708
800
  return createCancelledPythonResult(isTimedOutCancellation(err, executionOptions.signal));
709
801
  }
710
802
  throw err;
803
+ } finally {
804
+ if (deadlineTimer) clearTimeout(deadlineTimer);
805
+ if (combinedController) {
806
+ options?.signal?.removeEventListener("abort", forwardAbort);
807
+ deadlineController?.signal.removeEventListener("abort", forwardDeadlineAbort);
808
+ }
711
809
  }
712
810
  }
@@ -10,13 +10,17 @@
10
10
  */
11
11
  import { $env, isBunTestRuntime, logger, Snowflake } from "@gajae-code/utils";
12
12
  import type { Subprocess } from "bun";
13
- import { $ } from "bun";
14
13
  import { Settings } from "../../config/settings";
15
14
  import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
16
15
  import { resolvePythonIpcTrace, resolvePythonSkipCheck } from "./env";
17
16
  import { PYTHON_PRELUDE } from "./prelude";
18
17
  import { ensureRunnerScript } from "./runner-artifact";
19
- import { ensurePythonRuntime, filterEnv, type PythonRuntimeOptions } from "./runtime";
18
+ import {
19
+ ensurePythonRuntime,
20
+ filterEnv,
21
+ type PythonRuntimeLifecycleOptions,
22
+ type PythonRuntimeOptions,
23
+ } from "./runtime";
20
24
 
21
25
  export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
22
26
  export { renderKernelDisplay } from "./display";
@@ -103,32 +107,98 @@ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string)
103
107
  throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
104
108
  }
105
109
 
110
+ async function waitForLifecycle<T>(
111
+ value: Promise<T>,
112
+ lifecycle: PythonRuntimeLifecycleOptions | undefined,
113
+ message: string,
114
+ ): Promise<T> {
115
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
116
+ const cleanups: Array<() => void> = [];
117
+ const finish = (callback: () => void): void => {
118
+ while (cleanups.length > 0) cleanups.pop()?.();
119
+ callback();
120
+ };
121
+ if (lifecycle?.signal?.aborted) {
122
+ return Promise.reject(lifecycle.signal.reason ?? createAbortError("AbortError", message));
123
+ }
124
+ if (lifecycle?.signal) {
125
+ const onAbort = (): void =>
126
+ finish(() => reject(lifecycle.signal?.reason ?? createAbortError("AbortError", message)));
127
+ lifecycle.signal.addEventListener("abort", onAbort, { once: true });
128
+ cleanups.push(() => lifecycle.signal?.removeEventListener("abort", onAbort));
129
+ }
130
+ const remainingMs = getRemainingTimeMs(lifecycle?.deadlineMs);
131
+ if (remainingMs !== undefined) {
132
+ if (remainingMs <= 0) {
133
+ finish(() => reject(createAbortError("TimeoutError", `${message} timed out`)));
134
+ } else {
135
+ const timer = setTimeout(
136
+ () => finish(() => reject(createAbortError("TimeoutError", `${message} timed out`))),
137
+ remainingMs,
138
+ );
139
+ timer.unref();
140
+ cleanups.push(() => clearTimeout(timer));
141
+ }
142
+ }
143
+ void value.then(
144
+ result => finish(() => resolve(result)),
145
+ error => finish(() => reject(error)),
146
+ );
147
+ return await promise;
148
+ }
149
+
106
150
  export async function checkPythonKernelAvailability(
107
151
  cwd: string,
108
152
  runtimeOptions?: PythonRuntimeOptions,
153
+ lifecycle?: PythonRuntimeLifecycleOptions,
109
154
  ): Promise<PythonKernelAvailability> {
110
155
  if (isBunTestRuntime() || resolvePythonSkipCheck($env)) {
111
156
  return { ok: true };
112
157
  }
158
+ throwIfAborted(lifecycle?.signal, "Python kernel availability check aborted");
159
+ if (lifecycle?.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now()) {
160
+ throw createAbortError("TimeoutError", "Python kernel availability check timed out");
161
+ }
113
162
  try {
114
- const settings = await Settings.init();
163
+ const settings = await waitForLifecycle(Settings.init(), lifecycle, "Python kernel settings initialization");
115
164
  const { env } = settings.getShellConfig();
116
165
  const baseEnv = filterEnv(env);
117
- const runtime = await ensurePythonRuntime(cwd, baseEnv, runtimeOptions);
118
- const probe = await $`${runtime.pythonPath} -c "import sys;sys.exit(0)"`
119
- .quiet()
120
- .nothrow()
121
- .cwd(cwd)
122
- .env(runtime.env);
123
- if (probe.exitCode === 0) {
166
+ const runtime = await ensurePythonRuntime(cwd, baseEnv, runtimeOptions, lifecycle);
167
+ const probeEnv: Record<string, string> = {};
168
+ for (const [key, value] of Object.entries(runtime.env)) {
169
+ if (typeof value === "string") probeEnv[key] = value;
170
+ }
171
+ const remainingMs = getRemainingTimeMs(lifecycle?.deadlineMs);
172
+ if (remainingMs !== undefined && remainingMs <= 0) {
173
+ throw createAbortError("TimeoutError", "Python kernel availability check timed out");
174
+ }
175
+ const probe = Bun.spawn([runtime.pythonPath, "-c", "import sys;sys.exit(0)"], {
176
+ cwd,
177
+ env: probeEnv,
178
+ stdout: "ignore",
179
+ stderr: "ignore",
180
+ windowsHide: true,
181
+ signal: lifecycle?.signal,
182
+ timeout: remainingMs,
183
+ });
184
+ const exitCode = await probe.exited;
185
+ throwIfAborted(lifecycle?.signal, "Python kernel availability check aborted");
186
+ if (lifecycle?.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now()) {
187
+ throw createAbortError("TimeoutError", "Python kernel availability check timed out");
188
+ }
189
+ if (exitCode === 0) {
124
190
  return { ok: true, pythonPath: runtime.pythonPath };
125
191
  }
126
192
  return {
127
193
  ok: false,
128
194
  pythonPath: runtime.pythonPath,
129
- reason: `Python interpreter at ${runtime.pythonPath} returned exit code ${probe.exitCode}`,
195
+ reason: `Python interpreter at ${runtime.pythonPath} returned exit code ${exitCode}`,
130
196
  };
131
197
  } catch (err) {
198
+ throwIfAborted(lifecycle?.signal, "Python kernel availability check aborted");
199
+ if (lifecycle?.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now()) {
200
+ throw createAbortError("TimeoutError", "Python kernel availability check timed out");
201
+ }
132
202
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
133
203
  }
134
204
  }
@@ -186,6 +256,7 @@ export class PythonKernel {
186
256
  checkPythonKernelAvailability,
187
257
  options.cwd,
188
258
  options.runtimeOptions,
259
+ { signal: options.signal, deadlineMs: options.deadlineMs },
189
260
  );
190
261
  if (!availability.ok) {
191
262
  throw new Error(availability.reason ?? "Python kernel unavailable");
@@ -194,7 +265,10 @@ export class PythonKernel {
194
265
  const settings = await Settings.init();
195
266
  const { env: shellEnv } = settings.getShellConfig();
196
267
  const baseEnv = filterEnv(shellEnv);
197
- const runtime = await ensurePythonRuntime(options.cwd, baseEnv, options.runtimeOptions);
268
+ const runtime = await ensurePythonRuntime(options.cwd, baseEnv, options.runtimeOptions, {
269
+ signal: options.signal,
270
+ deadlineMs: options.deadlineMs,
271
+ });
198
272
  const spawnEnv: Record<string, string> = {};
199
273
  for (const [key, value] of Object.entries(runtime.env)) {
200
274
  if (typeof value === "string") spawnEnv[key] = value;