@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-11

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 (127) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  4. package/src/bridge/memory-protocol.ts +8 -1
  5. package/src/bridge/protocol.ts +9 -4
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +52 -0
  8. package/src/bridges/eval-virtual-schemas.ts +11 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/editable-check.ts +1 -1
  14. package/src/environments/js-environments.ts +208 -0
  15. package/src/environments/js-installer.ts +268 -0
  16. package/src/environments/js-revision-files.ts +91 -0
  17. package/src/environments/no-links.ts +132 -0
  18. package/src/environments/packages-install.ts +98 -0
  19. package/src/environments/project-resolves.ts +54 -0
  20. package/src/environments/py-environment.ts +7 -3
  21. package/src/environments/py-installer.ts +5 -1
  22. package/src/environments/python-environments.ts +1 -1
  23. package/src/environments/revision-store.ts +20 -4
  24. package/src/extension/bridge-tool-call.ts +5 -1
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/runtime-factory.ts +51 -13
  28. package/src/extension/runtime-info.ts +6 -2
  29. package/src/extension/session-cwd.ts +25 -9
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager.ts +20 -6
  32. package/src/index.ts +3 -2
  33. package/src/interpreters/detect.ts +34 -1
  34. package/src/kernels/AGENTS.md +2 -0
  35. package/src/kernels/jl/runner.jl +11 -22
  36. package/src/kernels/js/cell-dispatch.ts +44 -0
  37. package/src/kernels/js/cell-ownership.js +268 -0
  38. package/src/kernels/js/cell-run-context.js +89 -0
  39. package/src/kernels/js/context-manager.ts +46 -51
  40. package/src/kernels/js/control-frames.ts +24 -0
  41. package/src/kernels/js/group-signal-notice.js +117 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/inline-worker.ts +2 -0
  44. package/src/kernels/js/kernel-contract.ts +16 -1
  45. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  46. package/src/kernels/js/kernel-memory.ts +79 -0
  47. package/src/kernels/js/kernel-tools-registry.js +1 -0
  48. package/src/kernels/js/local-module-loader.ts +25 -6
  49. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  50. package/src/kernels/js/process-drain-scan.js +28 -0
  51. package/src/kernels/js/process-entry.js +466 -0
  52. package/src/kernels/js/process-memory-host.ts +83 -0
  53. package/src/kernels/js/process-tree-host.ts +4 -8
  54. package/src/kernels/js/process-tree.d.ts +11 -0
  55. package/src/kernels/js/process-tree.js +55 -10
  56. package/src/kernels/js/process-worker.ts +298 -0
  57. package/src/kernels/js/rejection-reports.js +61 -0
  58. package/src/kernels/js/run-queue.ts +7 -0
  59. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  60. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  61. package/src/kernels/js/windows-tree-kill.js +100 -0
  62. package/src/kernels/js/worker-core.js +53 -8
  63. package/src/kernels/js/worker-cwd.js +46 -12
  64. package/src/kernels/js/worker-host.ts +9 -2
  65. package/src/kernels/js/worker-indirect-eval.js +37 -13
  66. package/src/kernels/js/worker-package-resolve.js +153 -0
  67. package/src/kernels/js/worker-require.d.ts +19 -0
  68. package/src/kernels/js/worker-require.js +52 -0
  69. package/src/kernels/js/worker-runtime.js +66 -13
  70. package/src/kernels/js/worker-shadow-guard.js +2 -6
  71. package/src/kernels/js/worker-shell-capture.js +9 -0
  72. package/src/kernels/js/worker-slot.ts +8 -0
  73. package/src/kernels/js/worker-startup.ts +49 -1
  74. package/src/kernels/js/worker-webview.js +15 -1
  75. package/src/kernels/py/host-cell.ts +8 -31
  76. package/src/kernels/py/prelude.py +69 -27
  77. package/src/kernels/py/process.ts +4 -0
  78. package/src/kernels/py/transport.ts +4 -0
  79. package/src/kernels/rb/runner.rb +9 -19
  80. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  81. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  82. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  83. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  84. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  85. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  86. package/src/kernels/shared/bun-ffi.ts +24 -0
  87. package/src/kernels/shared/host-cell.ts +57 -0
  88. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  89. package/src/kernels/shared/process-cpu.ts +7 -3
  90. package/src/kernels/shared/process-group-cpu.ts +6 -3
  91. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  92. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  93. package/src/kernels/shared/subprocess-process.ts +24 -2
  94. package/src/kernels/shared/subprocess-queue.ts +1 -0
  95. package/src/prompt/eval-prompt.ts +8 -1
  96. package/src/tool/AGENTS.md +1 -0
  97. package/src/tool/cell-handler.ts +12 -11
  98. package/src/tool/cell-reserved-dispatch.ts +1 -0
  99. package/src/tool/cell-runtime.ts +4 -2
  100. package/src/tool/detached-cell-contract.ts +3 -0
  101. package/src/tool/detached-cell-manager.ts +4 -8
  102. package/src/tool/detached-cell-notification.ts +11 -63
  103. package/src/tool/detached-cell-snapshot.ts +17 -0
  104. package/src/tool/detached-eval-result.ts +13 -7
  105. package/src/tool/detached-notification-queue.ts +1 -2
  106. package/src/tool/eval-request.ts +23 -1
  107. package/src/tool/eval-tool-options.ts +3 -0
  108. package/src/tool/eval-tool.ts +19 -5
  109. package/src/tool/interrupt-note.ts +4 -5
  110. package/src/tool/live-headline.ts +60 -0
  111. package/src/tool/load-cell.ts +18 -3
  112. package/src/tool/magic-cell-host.ts +60 -2
  113. package/src/tool/magic-cells.ts +12 -2
  114. package/src/tool/managed-cell.ts +0 -4
  115. package/src/tool/render-agent.ts +110 -0
  116. package/src/tool/render-blocks.ts +252 -0
  117. package/src/tool/render-call.ts +130 -0
  118. package/src/tool/render-cell.ts +165 -0
  119. package/src/tool/render-detail.ts +152 -0
  120. package/src/tool/render-live.ts +288 -0
  121. package/src/tool/render-result.ts +202 -0
  122. package/src/tool/render-status.ts +166 -0
  123. package/src/tool/render-tail.ts +139 -0
  124. package/src/tool/render.ts +3 -1069
  125. package/src/tool/run-eval-cell.ts +53 -16
  126. package/src/tool/runtime-label.ts +2 -1
  127. package/src/tool/types.ts +34 -3
@@ -63,6 +63,23 @@ export function currentDetachedResult(cell: DetachedCellResultSource): AgentTool
63
63
  return cell.terminalResult ?? cell.liveResult?.() ?? fallbackResult(cell.input);
64
64
  }
65
65
 
66
+ /**
67
+ * The result a cell cancelled before its handler finished settles with: its buffered output, not the live progress
68
+ * frame ("1/1 cells running"), so a stopped cell never reads as still running.
69
+ */
70
+ export function cancelledDetachedResult(cell: DetachedCellResultSource): AgentToolResult<EvalToolDetails> {
71
+ if (cell.terminalResult !== undefined) return cell.terminalResult;
72
+ const current = currentDetachedResult(cell);
73
+ const output = detachedOutputTail(current);
74
+ return {
75
+ content: [
76
+ { type: "text", text: output.length > 0 ? output : "(no output)" },
77
+ ...current.content.filter((part) => part.type === "image"),
78
+ ],
79
+ details: current.details,
80
+ };
81
+ }
82
+
66
83
  export function detachedErrorResult(cell: DetachedCellResultSource, error: Error): AgentToolResult<EvalToolDetails> {
67
84
  const current = currentDetachedResult(cell);
68
85
  const output = detachedOutputTail(current);
@@ -46,9 +46,7 @@ function createEvalListResult(cellManager: EvalDetachedCellManager): AgentToolRe
46
46
  language: snapshot.language,
47
47
  state: snapshot.state,
48
48
  startedAtMs: snapshot.startedAtMs,
49
- ...(snapshot.queuedBehind === undefined || snapshot.queuedBehind.length === 0
50
- ? {}
51
- : { queuedBehind: [...snapshot.queuedBehind] }),
49
+ ...(snapshot.queuedBehind === undefined ? {} : { queuedBehind: [...snapshot.queuedBehind] }),
52
50
  ...(summary ? { summary } : {}),
53
51
  };
54
52
  });
@@ -60,7 +58,7 @@ function createEvalListResult(cellManager: EvalDetachedCellManager): AgentToolRe
60
58
  " ",
61
59
  );
62
60
  const elapsed = Math.floor(snapshot.result.details.durationMs / 1000);
63
- const queued = cell.queuedBehind === undefined ? "" : ` queued behind ${cell.queuedBehind.join(", ")}`;
61
+ const queued = cell.queuedBehind === undefined ? "" : ` ${queuedPhrase(cell.queuedBehind, cell.language)}`;
64
62
  return `${cell.cellId} ${cell.language} ${cell.state} ${elapsed}s${queued} - ${preview}`;
65
63
  })
66
64
  .join("\n");
@@ -76,11 +74,13 @@ export function resultAfterDetach(
76
74
  otherLiveCells: number,
77
75
  ): AgentToolResult<EvalToolDetails> {
78
76
  if (snapshot.state !== "detached" && snapshot.state !== "running") return createDetachedControlResult(snapshot);
79
- const predecessors = snapshot.queuedBehind?.join(", ");
77
+ const queuedBehind = snapshot.queuedBehind;
80
78
  const text =
81
- predecessors === undefined
79
+ queuedBehind === undefined
82
80
  ? `Eval cell ${snapshot.cellId} detached and is running in the ${input.language} kernel (${otherLiveCells} other live cells). Completion arrives as a notification; do not re-run it. eval({ action: "peek" | "stop", cell_id }) or eval({ action: "list" }).`
83
- : `Eval cell ${snapshot.cellId} is queued behind ${predecessors} in the ${input.language} kernel and detached; it runs after ${predecessors} and completes as one notification. peek/stop/list with eval({ action, cell_id })`;
81
+ : queuedBehind.length === 0
82
+ ? `Eval cell ${snapshot.cellId} is detached and waiting for the ${input.language} kernel to be ready; it runs first once the kernel is ready and completes as one notification. peek/stop/list with eval({ action, cell_id })`
83
+ : `Eval cell ${snapshot.cellId} is queued behind ${queuedBehind.join(", ")} in the ${input.language} kernel and detached; it runs after ${queuedBehind.join(", ")} and completes as one notification. peek/stop/list with eval({ action, cell_id })`;
84
84
  return {
85
85
  content: [
86
86
  {
@@ -164,6 +164,12 @@ export function resultForDetachedState(
164
164
  };
165
165
  }
166
166
 
167
+ function queuedPhrase(queuedBehind: readonly string[], language: string): string {
168
+ return queuedBehind.length === 0
169
+ ? `waiting for the ${language} kernel to be ready`
170
+ : `queued behind ${queuedBehind.join(", ")}`;
171
+ }
172
+
167
173
  function textContent(result: AgentToolResult<EvalToolDetails>): string {
168
174
  return result.content
169
175
  .filter((part) => part.type === "text")
@@ -3,7 +3,6 @@ import { buildDetachedCellNotification } from "./detached-cell-notification.ts";
3
3
 
4
4
  export interface PendingDetachedNotification {
5
5
  readonly snapshot: () => EvalDetachedCellSnapshot | Promise<EvalDetachedCellSnapshot>;
6
- readonly spillPath: string | undefined;
7
6
  }
8
7
 
9
8
  export class DetachedNotificationQueue {
@@ -30,7 +29,7 @@ export class DetachedNotificationQueue {
30
29
  const flush = Promise.resolve().then(async () => {
31
30
  const pending = this.#pending.splice(0);
32
31
  const notifications = await Promise.all(
33
- pending.map(async (item) => await buildDetachedCellNotification(await item.snapshot(), item.spillPath)),
32
+ pending.map(async (item) => buildDetachedCellNotification(await item.snapshot())),
34
33
  );
35
34
  this.#notifier?.notify(notifications);
36
35
  });
@@ -17,9 +17,19 @@ export function normalizeEvalSummary(value: unknown): string | undefined {
17
17
  return normalized.length === 0 ? undefined : normalized;
18
18
  }
19
19
 
20
+ export class EvalIsolateInvalidError extends TypeError {
21
+ readonly name = "EvalIsolateInvalidError";
22
+ readonly code = "eval_isolate_invalid";
23
+
24
+ constructor(reason: string) {
25
+ super(`eval_isolate_invalid: ${reason}`);
26
+ }
27
+ }
28
+
20
29
  export function parseEvalRequest(
21
30
  params: unknown,
22
31
  enabledLanguages: readonly EvalLanguage[] = evalLanguageOrder,
32
+ options: { readonly sandbox?: boolean } = {},
23
33
  ): EvalToolRequest {
24
34
  if (!isRecord(params)) throw new TypeError("eval parameters must be an object");
25
35
  if (params.action === "list") return { action: "list" };
@@ -30,7 +40,9 @@ export function parseEvalRequest(
30
40
  }
31
41
  if (params.action !== undefined && params.action !== "run")
32
42
  throw new TypeError(`Unknown eval action "${String(params.action)}"`);
33
- if (!isEvalLanguage(params.language)) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
43
+ if (params.language === undefined) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
44
+ if (!isEvalLanguage(params.language))
45
+ throw new TypeError(`eval run language must be one of: ${enabledLanguages.join(", ")}`);
34
46
  if (typeof params.code !== "string")
35
47
  throw new TypeError("eval run requires code — the cell body to execute, verbatim");
36
48
  const summary = normalizeEvalSummary(params.summary);
@@ -40,6 +52,15 @@ export function parseEvalRequest(
40
52
  );
41
53
  if (params.on_timeout !== undefined && params.on_timeout !== "detach" && params.on_timeout !== "error")
42
54
  throw new TypeError(`Unknown eval on_timeout value "${String(params.on_timeout)}"`);
55
+ if (params.isolate !== undefined && params.isolate !== false) {
56
+ if (params.isolate !== true) throw new EvalIsolateInvalidError("isolate must be true or false");
57
+ if (options.sandbox !== true) {
58
+ throw new EvalIsolateInvalidError("sandbox cells are turned off (set sandbox.enabled in codemode settings)");
59
+ }
60
+ if (params.language !== "js")
61
+ throw new EvalIsolateInvalidError('sandbox cells run JavaScript only (language: "js")');
62
+ if (params.reset === true) throw new EvalIsolateInvalidError("a sandbox cell starts fresh; reset does not apply");
63
+ }
43
64
  return {
44
65
  language: params.language,
45
66
  code: params.code,
@@ -48,6 +69,7 @@ export function parseEvalRequest(
48
69
  ...(typeof params.timeout === "number" ? { timeout: params.timeout } : {}),
49
70
  ...(params.on_timeout === "detach" || params.on_timeout === "error" ? { on_timeout: params.on_timeout } : {}),
50
71
  ...(typeof params.reset === "boolean" ? { reset: params.reset } : {}),
72
+ ...(params.isolate === true ? { isolate: true } : {}),
51
73
  };
52
74
  }
53
75
 
@@ -8,6 +8,7 @@ import type {
8
8
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
9
9
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
10
10
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
11
+ import type { JsEnvironments } from "../environments/js-environments.ts";
11
12
  import type { PythonEnvironments } from "../environments/python-environments.ts";
12
13
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
13
14
  import type { HandleRegistry } from "../handles/handle-registry.ts";
@@ -72,6 +73,8 @@ export interface CreateEvalToolOptions {
72
73
  readonly kernelPreludes?: () => readonly KernelPreludeContribution[];
73
74
  /** The session's Python environments: `%pip` / `%environment` cells and the import root of every Python cell. */
74
75
  readonly pythonEnvironments?: PythonEnvironments;
76
+ /** The session's managed JavaScript packages: `%bun add` / `%npm add` cells and the bare-import fallback root. */
77
+ readonly jsEnvironments?: JsEnvironments;
75
78
  /** Contributions whose documentation lines the description lists; snapshot taken when the tool is (re)registered. */
76
79
  readonly promptKernelPreludes?: readonly KernelPreludeContribution[];
77
80
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentToolResult, ToolDefinition } from "@code-yeongyu/senpi";
2
2
  import type { TUnsafe } from "typebox";
3
+ import { resolveAdvertiseHelpers, resolveSandbox } from "../config/feature-settings.ts";
3
4
  import {
4
5
  DEFAULT_FOREGROUND_WINDOW_SECONDS,
5
6
  defaultCodemodeSettings,
@@ -8,7 +9,12 @@ import {
8
9
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
9
10
  import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
10
11
  import { executeEvalControl } from "./detached-eval-result.ts";
11
- import { isEvalControlRequest, normalizeEvalSummary, parseEvalRequest } from "./eval-request.ts";
12
+ import {
13
+ EvalIsolateInvalidError,
14
+ isEvalControlRequest,
15
+ normalizeEvalSummary,
16
+ parseEvalRequest,
17
+ } from "./eval-request.ts";
12
18
  import type { CreateEvalToolOptions } from "./eval-tool-options.ts";
13
19
  import { runEvalCell } from "./run-eval-cell.ts";
14
20
  import {
@@ -19,6 +25,7 @@ import {
19
25
  type EvalListInput,
20
26
  type EvalResultDetails,
21
27
  type EvalToolDetails,
28
+ type EvalToolInput,
22
29
  type EvalToolRequest,
23
30
  enabledLanguageList,
24
31
  } from "./types.ts";
@@ -28,7 +35,7 @@ export type { CreateEvalToolOptions } from "./eval-tool-options.ts";
28
35
  export type { EnabledEvalLanguages, EvalKernel, EvalKernelManager } from "./types.ts";
29
36
 
30
37
  type EvalExecuteArgs<Request extends EvalToolRequest> = Parameters<
31
- ToolDefinition<TUnsafe<Request>, EvalToolDetails>["execute"]
38
+ ToolDefinition<TUnsafe<Request | Partial<EvalToolInput>>, EvalToolDetails>["execute"]
32
39
  >;
33
40
 
34
41
  export function createEvalTool(options: CreateEvalToolOptions) {
@@ -43,9 +50,11 @@ export function createEvalTool(options: CreateEvalToolOptions) {
43
50
  foregroundWindowSeconds,
44
51
  hardLimitSeconds: options.hardLimitSeconds ?? defaultEvalDeadlineSeconds.hardLimitSeconds,
45
52
  };
46
- const parameters = createEvalInputSchema(options.enabledLanguages, deadlines);
53
+ const sandbox = resolveSandbox(options.settings ?? defaultCodemodeSettings).enabled;
54
+ const parameters = createEvalInputSchema(options.enabledLanguages, deadlines, { sandbox });
47
55
  const prompt = buildEvalPrompt(options.enabledLanguages, {
48
56
  spawns: options.spawns ?? false,
57
+ ...(resolveAdvertiseHelpers(options.settings ?? defaultCodemodeSettings) ? { advertiseHelpers: true } : {}),
49
58
  monitor: options.monitor,
50
59
  maxDetachedCells,
51
60
  runBudgetSeconds: deadlines.runBudgetSeconds,
@@ -74,9 +83,14 @@ export function createEvalTool(options: CreateEvalToolOptions) {
74
83
  async function execute(
75
84
  ...[toolCallId, params, signal, onUpdate, ctx]: EvalExecuteArgs<EvalToolRequest>
76
85
  ): Promise<AgentToolResult<EvalResultDetails>> {
77
- const request = parseEvalRequest(params, languages);
86
+ const request = parseEvalRequest(params, languages, { sandbox });
78
87
  if (isEvalControlRequest(request)) return await executeEvalControl(cellManager, request);
79
- if (options.proxyExecutor) return await options.proxyExecutor(request, signal);
88
+ if (options.proxyExecutor) {
89
+ // A proxy runs cells elsewhere and knows nothing of sandbox cells: an isolated cell must never run unisolated.
90
+ if ("isolate" in request && request.isolate === true)
91
+ throw new EvalIsolateInvalidError("isolate: true is not available through this eval proxy");
92
+ return await options.proxyExecutor(request, signal);
93
+ }
80
94
  if (!languages.includes(request.language))
81
95
  throw new RangeError(
82
96
  `Unsupported eval language "${request.language}". Enabled languages: ${languages.join(", ")}`,
@@ -26,9 +26,8 @@ export async function describeTimeoutState(
26
26
  );
27
27
  if (outcome === undefined) error.message = fallbackTimeoutMessage(error.message);
28
28
  else if (outcome.retained)
29
- error.message = `${error.message} The kernel remains running; its existing variables are preserved.`;
30
- else
31
- error.message = `${error.message} The kernel was unresponsive and restarted; variables from earlier cells are lost.`;
29
+ error.message = `${error.message} The kernel was not restarted; variables from earlier cells are kept.`;
30
+ else error.message = `${error.message} The kernel was restarted; variables from earlier cells are lost.`;
32
31
  if (outcome?.note !== undefined) error.message = `${error.message} ${outcome.note.trim()}`;
33
32
  return error;
34
33
  }
@@ -63,8 +62,8 @@ const LANGUAGE_LABEL: Record<EvalLanguage, string> = {
63
62
  export function interruptionStateNote(language: EvalLanguage, stateRetained: boolean | undefined): string | undefined {
64
63
  if (stateRetained === undefined) return undefined;
65
64
  const label = LANGUAGE_LABEL[language];
66
- if (stateRetained) return `${label} was interrupted and remains running; its existing variables are preserved.`;
67
- return `${label} was unresponsive to interrupt and was restarted; variables from earlier cells are lost.`;
65
+ if (stateRetained) return `The ${label} was not restarted; variables from earlier cells are kept.`;
66
+ return `The ${label} was restarted; variables from earlier cells are lost.`;
68
67
  }
69
68
 
70
69
  export function unknownInterruptionStateNote(language: EvalLanguage): string {
@@ -0,0 +1,60 @@
1
+ import { sanitizeTerminalLabel, visibleWidth } from "@code-yeongyu/senpi";
2
+ import { type EvalCellResult, type EvalLanguage, evalLanguageOrder } from "./types.ts";
3
+
4
+ const HEADLINED_STATUSES: ReadonlySet<EvalCellResult["status"]> = new Set(["pending", "queued", "running", "detached"]);
5
+
6
+ /**
7
+ * A collapsed headline never wraps the frame: it gets what the language, state and timing leave on the line. This
8
+ * floor (in screen cells) only keeps a word or two visible in very narrow terminals.
9
+ */
10
+ const MIN_HEADLINE_CELLS = 8;
11
+
12
+ export function leadsWithHeadline(status: EvalCellResult["status"]): boolean {
13
+ return HEADLINED_STATUSES.has(status);
14
+ }
15
+
16
+ /**
17
+ * What an in-progress cell is doing, in one line: its summary, or else the first line of its code that has content once sanitized (the
18
+ * oh-my-pi fallback), or an ellipsis while the arguments are still streaming in. Collapsed rows cut it to
19
+ * `maxCells` screen cells (a wide character takes two), so the headline stays one line. Whichever is chosen is
20
+ * sanitized before it is measured, so escape and control characters never reach the row (senpi#2831, senpi#2839).
21
+ */
22
+ export function liveHeadline(
23
+ summary: string | undefined,
24
+ code: string | undefined,
25
+ maxCells: number | undefined,
26
+ ): string {
27
+ const text = (summary === undefined ? "" : sanitizeTerminalLabel(summary)) || firstCodeLine(code) || "…";
28
+ const limit = maxCells === undefined ? undefined : Math.max(MIN_HEADLINE_CELLS, maxCells);
29
+ if (limit === undefined || visibleWidth(text) <= limit) return text;
30
+ return `${cellPrefix(text, limit - 1)}…`;
31
+ }
32
+
33
+ // A plain cut by screen cells: the headline is styled by the caller, so no reset codes may be inserted here.
34
+ function cellPrefix(text: string, cells: number): string {
35
+ let kept = "";
36
+ let used = 0;
37
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
38
+ const width = visibleWidth(segment);
39
+ if (used + width > cells) break;
40
+ kept += segment;
41
+ used += width;
42
+ }
43
+ return kept;
44
+ }
45
+
46
+ export function knownLanguage(language: unknown): EvalLanguage | undefined {
47
+ return evalLanguageOrder.find((known) => known === language);
48
+ }
49
+
50
+ // The first line with something to show once it is sanitized: a line of only escape or control characters is skipped
51
+ // (senpi#2850), and the later lines never reach the row.
52
+ function firstCodeLine(code: string | undefined): string | undefined {
53
+ if (code === undefined) return undefined;
54
+ // Stops at the first line with content, so a long cell is not sanitized line by line on every frame.
55
+ for (const line of code.split("\n")) {
56
+ const clean = sanitizeTerminalLabel(line);
57
+ if (clean.length > 0) return clean;
58
+ }
59
+ return undefined;
60
+ }
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -50,6 +50,18 @@ function resolveTarget(target: string, options: LoadCellOptions): { path: string
50
50
  * Reads a `%load` target when its cell's turn comes in the kernel's queue, so a file the previous cell wrote is
51
51
  * there. Every failure is a refusal that names the target as the user wrote it, never an absolute path.
52
52
  */
53
+ /** Reads from `fd` until EOF or `limit` bytes, whichever comes first; a file still growing cannot exceed it. */
54
+ export function readAtMost(fd: number, limit: number): Buffer {
55
+ const buffer = Buffer.alloc(limit);
56
+ let filled = 0;
57
+ while (filled < limit) {
58
+ const read = readSync(fd, buffer, filled, limit - filled, null);
59
+ if (read === 0) break;
60
+ filled += read;
61
+ }
62
+ return buffer.subarray(0, filled);
63
+ }
64
+
53
65
  export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
54
66
  const resolved = resolveTarget(target, options);
55
67
  if ("message" in resolved) return { ok: false, message: resolved.message };
@@ -60,8 +72,11 @@ export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
60
72
  fd = openSync(resolved.path, constants.O_RDONLY | constants.O_NONBLOCK);
61
73
  const info = fstatSync(fd);
62
74
  if (!info.isFile()) return { ok: false, message: `not a file: ${target}` };
63
- if (info.size > MAX_LOAD_BYTES) return { ok: false, message: `%load reads files up to 8 MiB: ${target}` };
64
- return { ok: true, code: readFileSync(fd, "utf8"), sourceFile: resolved.path };
75
+ const tooLarge = { ok: false as const, message: `%load reads files up to 8 MiB: ${target}` };
76
+ if (info.size > MAX_LOAD_BYTES) return tooLarge;
77
+ const bytes = readAtMost(fd, MAX_LOAD_BYTES + 1);
78
+ if (bytes.length > MAX_LOAD_BYTES) return tooLarge;
79
+ return { ok: true, code: bytes.toString("utf8"), sourceFile: resolved.path };
65
80
  } catch (error) {
66
81
  const code = errorCode(error);
67
82
  if (code === "ENOENT" || code === "ENOTDIR") return { ok: false, message: `file not found: ${target}` };
@@ -1,3 +1,4 @@
1
+ import type { JsEnvironments } from "../environments/js-environments.ts";
1
2
  import type { PythonEnvironments } from "../environments/python-environments.ts";
2
3
  import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
3
4
  import { MagicCellError, parseMagicCell } from "./magic-cells.ts";
@@ -13,6 +14,7 @@ export function planMagicCell(
13
14
  language: EvalLanguage,
14
15
  code: string,
15
16
  environments: PythonEnvironments | undefined,
17
+ jsEnvironments?: JsEnvironments,
16
18
  ): MagicCellPlan {
17
19
  let magic: ReturnType<typeof parseMagicCell>;
18
20
  try {
@@ -23,6 +25,7 @@ export function planMagicCell(
23
25
  }
24
26
  if (magic === undefined) return { kind: "ordinary" };
25
27
  if (magic.kind === "load") return { kind: "load", target: magic.target };
28
+ if (language === "js") return planJsMagic(magic, jsEnvironments);
26
29
  if (environments === undefined) {
27
30
  return {
28
31
  kind: "refused",
@@ -34,8 +37,9 @@ export function planMagicCell(
34
37
  return {
35
38
  kind: "host",
36
39
  executor: async () => {
37
- const root = await environments.setMode(mode);
38
- return { ok: true, valueRepr: `environment: ${mode} (${root})` };
40
+ await environments.setMode(mode);
41
+ const where = mode === "project" ? "the session directory" : "this session's managed revisions";
42
+ return { ok: true, valueRepr: `environment: ${mode} (${where})` };
39
43
  },
40
44
  };
41
45
  }
@@ -63,3 +67,57 @@ export function planMagicCell(
63
67
  },
64
68
  };
65
69
  }
70
+
71
+ function planJsMagic(
72
+ magic: Exclude<NonNullable<ReturnType<typeof parseMagicCell>>, { kind: "load" }>,
73
+ environments: JsEnvironments | undefined,
74
+ ): MagicCellPlan {
75
+ if (environments === undefined) {
76
+ return {
77
+ kind: "refused",
78
+ message: "environment_installer_unavailable: this session has no JavaScript package environment",
79
+ };
80
+ }
81
+ if (magic.kind === "environment") {
82
+ const mode = magic.mode;
83
+ return {
84
+ kind: "host",
85
+ executor: async () => {
86
+ await environments.setMode(mode);
87
+ const where = mode === "project" ? "the session directory" : "this session's managed revisions";
88
+ return { ok: true, valueRepr: `environment: ${mode} (${where})` };
89
+ },
90
+ };
91
+ }
92
+ if (magic.kind !== "js-add") return { kind: "refused", message: "this magic is not available in JavaScript" };
93
+ const requested = magic.args;
94
+ return {
95
+ kind: "host",
96
+ executor: async ({ signal, emit }) => {
97
+ emit({ type: "status", event: { op: TIMEOUT_PAUSE_OP } });
98
+ try {
99
+ const receipt = await environments.install(
100
+ requested,
101
+ signal,
102
+ (stream, data) => emit({ type: "text", stream, data }),
103
+ magic.installer,
104
+ );
105
+ const added = receipt.added.length > 0 ? receipt.added.join(", ") : "nothing new";
106
+ const where =
107
+ receipt.revision === undefined ? receipt.mode : `${receipt.mode} (revision ${receipt.revision})`;
108
+ const shadow =
109
+ receipt.shadowed.length > 0
110
+ ? `; environment_resolution_conflict: ${receipt.shadowed.join(", ")} still ${receipt.shadowed.length === 1 ? "resolves" : "resolve"} from the project's node_modules first`
111
+ : "";
112
+ return {
113
+ ok: true,
114
+ valueRepr: `added ${added} with ${receipt.installer} into ${where}${shadow}; already-imported modules stay cached until reset`,
115
+ };
116
+ } catch (error) {
117
+ return { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
118
+ } finally {
119
+ emit({ type: "status", event: { op: TIMEOUT_RESUME_OP } });
120
+ }
121
+ },
122
+ };
123
+ }
@@ -3,9 +3,10 @@ import type { EvalLanguage } from "./types.ts";
3
3
  export type MagicCell =
4
4
  | { readonly kind: "pip"; readonly args: string }
5
5
  | { readonly kind: "environment"; readonly mode: "managed" | "project" }
6
- | { readonly kind: "load"; readonly target: string };
6
+ | { readonly kind: "load"; readonly target: string }
7
+ | { readonly kind: "js-add"; readonly installer: "bun" | "npm"; readonly args: string };
7
8
 
8
- const HOST_MAGICS = ["pip", "environment", "load"] as const;
9
+ const HOST_MAGICS = ["pip", "environment", "load", "bun", "npm"] as const;
9
10
  const LOAD_LANGUAGES: ReadonlySet<EvalLanguage> = new Set(["py", "js"]);
10
11
  const COMMENT_PREFIX: Partial<Record<EvalLanguage, string>> = { py: "#", js: "//" };
11
12
  type HostMagic = (typeof HOST_MAGICS)[number];
@@ -34,6 +35,8 @@ function hostMagicOf(language: EvalLanguage, line: string): HostMagic | undefine
34
35
  const name = match?.[1];
35
36
  const magic = HOST_MAGICS.find((candidate) => candidate === name);
36
37
  if (magic === "load") return LOAD_LANGUAGES.has(language) ? magic : undefined;
38
+ if (magic === "bun" || magic === "npm") return language === "js" ? magic : undefined;
39
+ if (magic === "environment") return language === "py" || language === "js" ? magic : undefined;
37
40
  return language === "py" ? magic : undefined;
38
41
  }
39
42
 
@@ -56,6 +59,13 @@ export function parseMagicCell(language: EvalLanguage, code: string): MagicCell
56
59
  throw new MagicCellError(`put %${magic} on its own cell, then run the code that uses it in the next cell`);
57
60
  const args = first.trim().slice(`%${magic}`.length).trim();
58
61
  if (magic === "pip") return { kind: "pip", args };
62
+ if (magic === "bun" || magic === "npm") {
63
+ const [verb, ...rest] = args.split(/\s+/);
64
+ if (verb !== "add" && verb !== "install") {
65
+ throw new MagicCellError(`%${magic} supports only add: %${magic} add <package ...>`);
66
+ }
67
+ return { kind: "js-add", installer: magic, args: rest.join(" ") };
68
+ }
59
69
  if (magic === "load") {
60
70
  if (args === "") throw new MagicCellError("%load takes one argument: the path of the file to run");
61
71
  return { kind: "load", target: args };
@@ -1,7 +1,6 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
2
  import { type CellDeadlineExpiry, CellDeadlines } from "./cell-deadlines.ts";
3
3
  import type { EvalDetachedCellSnapshot, EvalDetachedCellState } from "./detached-cell-contract.ts";
4
- import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
5
4
  import type { EvalKernel, EvalToolDetails, EvalToolInput } from "./types.ts";
6
5
 
7
6
  export type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
@@ -9,7 +8,6 @@ export type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
9
8
  export type ManagedCell = {
10
9
  readonly cellId: string;
11
10
  readonly input: EvalToolInput;
12
- readonly spillPath: string | undefined;
13
11
  readonly startedAtMs: number;
14
12
  readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
15
13
  state: Exclude<EvalDetachedCellState, "detached">;
@@ -37,7 +35,6 @@ export type ManagedCell = {
37
35
  export interface ManagedCellInit {
38
36
  readonly cellId: string;
39
37
  readonly input: EvalToolInput;
40
- readonly artifactsDir: string | undefined;
41
38
  readonly now: () => number;
42
39
  readonly defaultHardLimitSeconds: number;
43
40
  readonly defaultRunBudgetSeconds: number;
@@ -60,7 +57,6 @@ export function createManagedCell(init: ManagedCellInit): ManagedCell {
60
57
  return {
61
58
  cellId: init.cellId,
62
59
  input: init.input,
63
- spillPath: detachedNotificationSpillPath(init.artifactsDir, init.cellId),
64
60
  startedAtMs: init.now(),
65
61
  state: "queued",
66
62
  runStartedAtMs: undefined,
@@ -0,0 +1,110 @@
1
+ import {
2
+ appendLines,
3
+ assertNever,
4
+ type RenderEnvironment,
5
+ renderPrefixed,
6
+ type StatusPresentation,
7
+ spinner,
8
+ style,
9
+ } from "./render-blocks.ts";
10
+ import { eventString } from "./render-status.ts";
11
+ import { formatDuration } from "./tool-widgets.ts";
12
+ import type { EvalStatusEvent } from "./types.ts";
13
+
14
+ type AgentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
15
+
16
+ function eventNumber(value: unknown): number {
17
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
18
+ }
19
+
20
+ function agentStatus(value: unknown): AgentStatus {
21
+ switch (value) {
22
+ case "pending":
23
+ case "running":
24
+ case "completed":
25
+ case "failed":
26
+ case "aborted":
27
+ return value;
28
+ default:
29
+ return "running";
30
+ }
31
+ }
32
+
33
+ function coalesceAgentEvents(events: readonly EvalStatusEvent[]): EvalStatusEvent[] {
34
+ const rows: EvalStatusEvent[] = [];
35
+ const indexes = new Map<string, number>();
36
+ for (const event of events) {
37
+ const id = eventString(event.id);
38
+ if (id === undefined) {
39
+ rows.push(event);
40
+ continue;
41
+ }
42
+ const index = indexes.get(id);
43
+ if (index === undefined) {
44
+ indexes.set(id, rows.length);
45
+ rows.push(event);
46
+ } else rows[index] = event;
47
+ }
48
+ return rows;
49
+ }
50
+
51
+ function agentPresentation(status: AgentStatus, spinnerFrame: number | undefined): StatusPresentation {
52
+ switch (status) {
53
+ case "pending":
54
+ return { label: "pending", icon: "○", color: "muted" };
55
+ case "running":
56
+ return { label: "running", icon: spinner(spinnerFrame), color: "warning" };
57
+ case "completed":
58
+ return { label: "done", icon: "✓", color: "success" };
59
+ case "failed":
60
+ return { label: "failed", icon: "✗", color: "error" };
61
+ case "aborted":
62
+ return { label: "aborted", icon: "×", color: "error" };
63
+ default:
64
+ return assertNever(status);
65
+ }
66
+ }
67
+
68
+ export function renderAgentProgressEvents(
69
+ events: readonly EvalStatusEvent[],
70
+ environment: RenderEnvironment,
71
+ ): string[] {
72
+ const rows = coalesceAgentEvents(events);
73
+ const lines: string[] = [];
74
+ // Senpi Theme has no tree-token API; fixed ├/└/│ glyphs intentionally mirror omp.
75
+ for (const [index, event] of rows.entries()) {
76
+ const isLast = index === rows.length - 1;
77
+ const status = agentStatus(event.status);
78
+ const presentation = agentPresentation(status, environment.spinnerFrame);
79
+ const id = eventString(event.id) ?? "agent";
80
+ const styledId = environment.theme === undefined ? id : environment.theme.bold(id);
81
+ let body = `${style(environment.theme, presentation.color, presentation.icon)} ${styledId} ${presentation.label}`;
82
+ if (status === "completed" || status === "failed" || status === "aborted") {
83
+ const duration = eventNumber(event.durationMs);
84
+ if (duration > 0) body += ` · ${style(environment.theme, "dim", formatDuration(duration))}`;
85
+ }
86
+ const branch = isLast ? "└ " : "├ ";
87
+ const continuation = isLast ? " " : "│ ";
88
+ appendLines(
89
+ lines,
90
+ renderPrefixed(body, environment, { prefix: branch, continuation: continuation, color: "dim" }),
91
+ );
92
+ if (status !== "running") continue;
93
+ const currentTool = eventString(event.currentTool);
94
+ const lastIntent = eventString(event.lastIntent);
95
+ if (currentTool === undefined && lastIntent === undefined) continue;
96
+ const detail =
97
+ currentTool === undefined
98
+ ? (lastIntent ?? "")
99
+ : `${currentTool}${lastIntent === undefined ? "" : `: ${lastIntent}`}`;
100
+ appendLines(
101
+ lines,
102
+ renderPrefixed(detail, environment, {
103
+ prefix: `${continuation}└ `,
104
+ continuation: `${continuation} `,
105
+ color: "dim",
106
+ }),
107
+ );
108
+ }
109
+ return lines;
110
+ }