@code-yeongyu/senpi-codemode 2026.7.31 → 2026.8.3-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,75 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.8.3-3] - 2026-08-03
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.8.3-2] - 2026-08-03
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ ### Fixed
36
+
37
+ ### Removed
38
+
39
+ ## [2026.8.3] - 2026-08-03
40
+
41
+ ### Breaking Changes
42
+
43
+ ### Added
44
+
45
+ ### Changed
46
+
47
+ ### Fixed
48
+
49
+ ### Removed
50
+
51
+ ## [2026.8.1] - 2026-08-01
52
+
53
+ ### Breaking Changes
54
+
55
+ ### Added
56
+
57
+ ### Changed
58
+
59
+ ### Fixed
60
+
61
+ - Preserve rich live and terminal `eval` details when peeking detached cells,
62
+ including code, title, output, phase, status events, tool-call summaries,
63
+ duration, and structured displays; cancellation now remains authoritative
64
+ over late completion races
65
+ ([#603](https://github.com/code-yeongyu/senpi/pull/603)).
66
+
67
+ ### Removed
68
+
69
+ ## [2026.7.31-2] - 2026-07-31
70
+
71
+ ### Breaking Changes
72
+
73
+ ### Added
74
+
75
+ ### Changed
76
+
77
+ - Include a live elapsed label in detached `eval` footer status. The ticker updates only when the rendered duration
78
+ changes and is disposed when the cell completes, fails, or is stopped.
79
+
80
+ ### Fixed
81
+
82
+ ### Removed
83
+
15
84
  ## [2026.7.31] - 2026-07-31
16
85
 
17
86
  ### Breaking Changes
package/README.md CHANGED
@@ -11,9 +11,12 @@ task-tool names are known.
11
11
  - Persistent JavaScript, Python, Ruby, and Julia cells. State survives later
12
12
  cells in the same language until reset, restart, or session disposal.
13
13
  - Timeout detachment for interactive `eval`: long pure-compute cells return a
14
- handle and continue in their existing kernel. Completion is injected with the
15
- final value/error and buffered output; use `eval({ action: "peek"|"stop",
16
- cell_id })` to inspect or terminate a detached cell.
14
+ handle and continue in their existing kernel. Completion is injected with the
15
+ final value/error and buffered output; use `eval({ action: "peek"|"stop",
16
+ cell_id })` to inspect or terminate a detached cell. A running peek preserves
17
+ the original code and title together with current output, phase, status
18
+ events, tool-call summaries, elapsed duration, and structured display state;
19
+ a terminal peek preserves the exact final result.
17
20
  - Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
18
21
  - Structured status events for file operations, environment access, phases,
19
22
  bridge activity, and delegated task progress.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.7.31",
3
+ "version": "2026.8.3-3",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.31",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.3-3",
34
34
  "typebox": "1.3.8"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "*"
37
+ "@code-yeongyu/senpi": "2026.8.3-3"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.7.31"
40
+ "@code-yeongyu/senpi": "2026.8.3-3"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -0,0 +1,79 @@
1
+ import type { EvalDetachedCellStatusEntry } from "../tool/detached-cell-manager.ts";
2
+ import { formatEvalCellStatus } from "./eval-status.ts";
3
+
4
+ /** Footer live-elapsed refresh cadence while at least one detached cell is running. */
5
+ export const EVAL_STATUS_TICK_INTERVAL_MS = 1000;
6
+
7
+ /** Receives the freshly formatted footer status text (undefined clears the status). */
8
+ export type EvalStatusRender = (status: string | undefined) => void;
9
+
10
+ export interface EvalStatusTickerOptions {
11
+ readonly render: EvalStatusRender;
12
+ /** Injectable clock for tests; defaults to Date.now. */
13
+ readonly now?: () => number;
14
+ }
15
+
16
+ /**
17
+ * Drives a once-per-second footer refresh while detached eval cells are running so
18
+ * the "↗ py · … (Ns)" elapsed label advances live instead of freezing between
19
+ * cell-set transitions. Same shape as the terminal builtin's MonitorStatusTicker:
20
+ * the interval is unref'd, and ticks producing the already-rendered label are skipped.
21
+ */
22
+ export class EvalStatusTicker {
23
+ private readonly render: EvalStatusRender;
24
+ private readonly now: () => number;
25
+ private intervalId: NodeJS.Timeout | undefined;
26
+ private entries: readonly EvalDetachedCellStatusEntry[] = [];
27
+ private lastRenderedStatus: string | undefined;
28
+ private hasRendered = false;
29
+
30
+ constructor(options: EvalStatusTickerOptions) {
31
+ this.render = options.render;
32
+ this.now = options.now ?? Date.now;
33
+ }
34
+
35
+ get running(): boolean {
36
+ return this.intervalId !== undefined;
37
+ }
38
+
39
+ /**
40
+ * Point the ticker at the current detached-cell set, render once immediately,
41
+ * and start the interval while cells are live (or stop it when none remain).
42
+ */
43
+ sync(entries: readonly EvalDetachedCellStatusEntry[]): void {
44
+ this.entries = entries;
45
+ this.hasRendered = false;
46
+ this.tick();
47
+ if (entries.length === 0) {
48
+ this.stopInterval();
49
+ return;
50
+ }
51
+ if (this.intervalId !== undefined) return;
52
+ const handle = setInterval(() => this.tick(), EVAL_STATUS_TICK_INTERVAL_MS);
53
+ handle.unref();
54
+ this.intervalId = handle;
55
+ }
56
+
57
+ /** Stop the interval and drop the retained entries. */
58
+ stop(): void {
59
+ this.stopInterval();
60
+ this.entries = [];
61
+ this.lastRenderedStatus = undefined;
62
+ this.hasRendered = false;
63
+ }
64
+
65
+ private stopInterval(): void {
66
+ if (this.intervalId !== undefined) {
67
+ clearInterval(this.intervalId);
68
+ this.intervalId = undefined;
69
+ }
70
+ }
71
+
72
+ private tick(): void {
73
+ const status = formatEvalCellStatus(this.entries, this.now());
74
+ if (this.hasRendered && status === this.lastRenderedStatus) return;
75
+ this.hasRendered = true;
76
+ this.lastRenderedStatus = status;
77
+ this.render(status);
78
+ }
79
+ }
@@ -31,14 +31,47 @@ function labelOf(entry: EvalDetachedCellStatusEntry): string {
31
31
  return entry.title === undefined || entry.title.length === 0 ? entry.cellId : entry.title;
32
32
  }
33
33
 
34
+ /**
35
+ * Goal-style compact elapsed label (`5s`, `3m`, `2h 30m`, `1d 2h 3m`). Mirrors
36
+ * the monitor builtin's formatElapsedSeconds; kept local like the rest of the
37
+ * duplicated status-file helpers between the two packages.
38
+ */
39
+ export function formatElapsedSeconds(value: number): string {
40
+ const seconds = Math.max(0, Math.trunc(value));
41
+ if (seconds < 60) return `${seconds}s`;
42
+ const minutes = Math.trunc(seconds / 60);
43
+ if (minutes < 60) return `${minutes}m`;
44
+ const hours = Math.trunc(minutes / 60);
45
+ const remainingMinutes = minutes % 60;
46
+ if (hours >= 24) {
47
+ const days = Math.trunc(hours / 24);
48
+ const remainingHours = hours % 24;
49
+ return `${days}d ${remainingHours}h ${remainingMinutes}m`;
50
+ }
51
+ if (remainingMinutes === 0) return `${hours}h`;
52
+ return `${hours}h ${remainingMinutes}m`;
53
+ }
54
+
55
+ /** Whole seconds since the oldest detached cell was created; never negative on clock skew. */
56
+ export function evalCellElapsedSeconds(entries: readonly EvalDetachedCellStatusEntry[], nowMs: number): number {
57
+ let oldest = Number.POSITIVE_INFINITY;
58
+ for (const entry of entries) oldest = Math.min(oldest, entry.startedAtMs);
59
+ if (!Number.isFinite(oldest)) return 0;
60
+ return Math.max(0, Math.round((nowMs - oldest) / 1000));
61
+ }
62
+
34
63
  /** Brief footer text for the cells still running detached; undefined clears the status. */
35
- export function formatEvalCellStatus(entries: readonly EvalDetachedCellStatusEntry[]): string | undefined {
64
+ export function formatEvalCellStatus(
65
+ entries: readonly EvalDetachedCellStatusEntry[],
66
+ nowMs: number,
67
+ ): string | undefined {
36
68
  const first = entries[0];
37
69
  if (first === undefined) return undefined;
70
+ const suffix = ` (${formatElapsedSeconds(evalCellElapsedSeconds(entries, nowMs))})`;
38
71
  if (entries.length === 1) {
39
72
  const head = `${DETACHED_GLYPH} ${first.language} · `;
40
- return head + truncateEnd(labelOf(first), MAX_STATUS_LENGTH - head.length);
73
+ return head + truncateEnd(labelOf(first), MAX_STATUS_LENGTH - head.length - suffix.length) + suffix;
41
74
  }
42
75
  const head = `${DETACHED_GLYPH} eval ${entries.length}: `;
43
- return head + packLabels(entries.map(labelOf), MAX_STATUS_LENGTH - head.length);
76
+ return head + packLabels(entries.map(labelOf), MAX_STATUS_LENGTH - head.length - suffix.length) + suffix;
44
77
  }
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@ import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts";
5
5
  import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
6
6
  import { defaultCodemodeSettings } from "./config/settings.ts";
7
7
  import { EvalNotifier } from "./extension/eval-notifier.ts";
8
- import { EVAL_CELLS_STATUS_KEY, formatEvalCellStatus } from "./extension/eval-status.ts";
8
+ import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
9
+ import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
9
10
  import {
10
11
  createExecuteTool,
11
12
  createRuntime,
@@ -44,6 +45,8 @@ export interface SenpiCodemodeOptions {
44
45
  options: CreateCodemodeSessionManagerOptions,
45
46
  ) => CodemodeSessionManager | Promise<CodemodeSessionManager>;
46
47
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
48
+ /** Injectable clock for detached-cell elapsed labels; defaults to Date.now. */
49
+ readonly now?: () => number;
47
50
  }
48
51
 
49
52
  export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCodemodeOptions = {}): void {
@@ -59,17 +62,22 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
59
62
  getContext: () => activeContext,
60
63
  getMode: () => "wake",
61
64
  });
65
+ const statusTicker = new EvalStatusTicker({
66
+ ...(options.now === undefined ? {} : { now: options.now }),
67
+ render: (status) => {
68
+ const ctx = activeContext;
69
+ if (ctx?.ui?.setStatus === undefined) return;
70
+ const theme = ctx.ui.theme;
71
+ ctx.ui.setStatus(
72
+ EVAL_CELLS_STATUS_KEY,
73
+ status === undefined || ctx.mode !== "tui" || theme === undefined
74
+ ? status
75
+ : theme.bg("selectedBg", theme.fg("text", status)),
76
+ );
77
+ },
78
+ });
62
79
  const showDetachedCells = (entries: readonly EvalDetachedCellStatusEntry[]): void => {
63
- const ctx = activeContext;
64
- if (ctx?.ui?.setStatus === undefined) return;
65
- const status = formatEvalCellStatus(entries);
66
- const theme = ctx.ui.theme;
67
- ctx.ui.setStatus(
68
- EVAL_CELLS_STATUS_KEY,
69
- status === undefined || ctx.mode !== "tui" || theme === undefined
70
- ? status
71
- : theme.bg("selectedBg", theme.fg("text", status)),
72
- );
80
+ statusTicker.sync(entries);
73
81
  };
74
82
  const registerEvalForRuntime = (
75
83
  runtime: SessionRuntime,
@@ -101,6 +109,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
101
109
  activeRuntime = undefined;
102
110
  activeModelId = undefined;
103
111
  activeCells = undefined;
112
+ statusTicker.stop();
104
113
  await cells?.dispose();
105
114
  activeContext = undefined;
106
115
  await manager.dispose();
@@ -114,7 +123,11 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
114
123
  listTools: () => pi.getAllTools(),
115
124
  complete,
116
125
  settings: defaultCodemodeSettings,
117
- cellManager: new EvalDetachedCellManager({ notifier, onStatusChange: showDetachedCells }),
126
+ cellManager: new EvalDetachedCellManager({
127
+ notifier,
128
+ onStatusChange: showDetachedCells,
129
+ ...(options.now === undefined ? {} : { now: options.now }),
130
+ }),
118
131
  executionTracker: manager,
119
132
  renderers,
120
133
  hostLine: hostLine(),
@@ -143,6 +156,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
143
156
  artifactsDir: runtime.artifactsDir,
144
157
  notifier,
145
158
  onStatusChange: showDetachedCells,
159
+ ...(options.now === undefined ? {} : { now: options.now }),
146
160
  });
147
161
  activeCells = cellManager;
148
162
  activeRuntime = runtime;
@@ -0,0 +1,146 @@
1
+ import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
2
+ import type { EvalKernel } from "./types.ts";
3
+
4
+ const INTERRUPT_DELIVERY_GRACE_MS = 100;
5
+
6
+ export interface EvalTimeoutFactory {
7
+ create(options: IdleTimeoutOptions): TimeoutPauseHandle & { dispose(): void };
8
+ }
9
+
10
+ export const defaultTimeoutFactory: EvalTimeoutFactory = {
11
+ create(options): IdleTimeout {
12
+ return new IdleTimeout(options);
13
+ },
14
+ };
15
+
16
+ export interface CellExecutionOptions {
17
+ readonly callerSignal: AbortSignal;
18
+ readonly cellId: string;
19
+ readonly timeoutMs: number;
20
+ readonly timeoutFactory: EvalTimeoutFactory;
21
+ readonly onTimeout: (error: Error) => void;
22
+ readonly onAbort: (error: Error) => void;
23
+ }
24
+
25
+ export class CellExecution {
26
+ readonly #callerSignal: AbortSignal;
27
+ readonly #onAbort: (error: Error) => void;
28
+ readonly #abortPromise: Promise<never>;
29
+ readonly #detachedPromise: Promise<void>;
30
+ readonly #watchdog: TimeoutPauseHandle & { dispose(): void };
31
+ #rejectAbort: ((reason?: unknown) => void) | undefined;
32
+ #resolveDetached: (() => void) | undefined;
33
+ #kernel: EvalKernel | undefined;
34
+ #interruptDeadline: ReturnType<typeof setTimeout> | undefined;
35
+ #active = true;
36
+
37
+ constructor(options: CellExecutionOptions) {
38
+ this.#callerSignal = options.callerSignal;
39
+ this.#onAbort = options.onAbort;
40
+ this.#abortPromise = new Promise<never>((_resolve, reject) => {
41
+ this.#rejectAbort = reject;
42
+ });
43
+ this.#detachedPromise = new Promise<void>((resolve) => {
44
+ this.#resolveDetached = resolve;
45
+ });
46
+ this.#watchdog = options.timeoutFactory.create({
47
+ cellId: options.cellId,
48
+ timeoutMs: options.timeoutMs,
49
+ onTimeout: ({ error }) => options.onTimeout(error),
50
+ });
51
+ this.#callerSignal.addEventListener("abort", this.#handleCallerAbort, {
52
+ once: true,
53
+ });
54
+ }
55
+
56
+ get detached(): Promise<void> {
57
+ return this.#detachedPromise;
58
+ }
59
+
60
+ pause(): void {
61
+ this.#watchdog.pause();
62
+ }
63
+
64
+ resume(): void {
65
+ this.#watchdog.resume();
66
+ }
67
+
68
+ setKernel(kernel: EvalKernel): void {
69
+ this.#kernel = kernel;
70
+ }
71
+
72
+ detach(): void {
73
+ if (!this.#active) return;
74
+ this.#watchdog.dispose();
75
+ this.#resolveDetached?.();
76
+ this.#resolveDetached = undefined;
77
+ }
78
+
79
+ cancel(reason: unknown): void {
80
+ this.#abort(reason);
81
+ }
82
+
83
+ finish(): void {
84
+ this.#active = false;
85
+ this.#cleanup();
86
+ }
87
+
88
+ async wait<Result>(operation: Promise<Result>): Promise<Result> {
89
+ const guarded = operation.then(
90
+ (value): Result | Promise<never> => (this.#active ? value : this.#abortPromise),
91
+ (reason: unknown): Promise<never> => (this.#active ? Promise.reject(reason) : this.#abortPromise),
92
+ );
93
+ return await Promise.race([guarded, this.#abortPromise]);
94
+ }
95
+
96
+ readonly #handleCallerAbort = (): void => {
97
+ this.#abort(this.#callerSignal.reason);
98
+ };
99
+
100
+ interruptStateRetained: Promise<boolean> | undefined;
101
+
102
+ #abort(reason: unknown): void {
103
+ if (!this.#active) return;
104
+ this.#active = false;
105
+ this.#cleanup();
106
+ const error = abortError(reason);
107
+ this.#onAbort(error);
108
+ const kernel = this.#kernel;
109
+ if (kernel === undefined) {
110
+ this.#settleAbort(error);
111
+ return;
112
+ }
113
+ this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
114
+ void Promise.resolve()
115
+ .then(async () => {
116
+ const handle = await kernel.interrupt(error.message);
117
+ this.interruptStateRetained = handle?.stateRetained;
118
+ })
119
+ .then(
120
+ () => this.#settleAbort(error),
121
+ (interruptError: unknown) => this.#settleAbort(interruptError),
122
+ );
123
+ }
124
+
125
+ #settleAbort(reason: unknown): void {
126
+ const reject = this.#rejectAbort;
127
+ if (reject === undefined) return;
128
+ this.#rejectAbort = undefined;
129
+ if (this.#interruptDeadline !== undefined) clearTimeout(this.#interruptDeadline);
130
+ reject(reason);
131
+ }
132
+
133
+ #cleanup(): void {
134
+ this.#callerSignal.removeEventListener("abort", this.#handleCallerAbort);
135
+ this.#watchdog.dispose();
136
+ if (this.#interruptDeadline !== undefined) clearTimeout(this.#interruptDeadline);
137
+ this.#interruptDeadline = undefined;
138
+ }
139
+ }
140
+
141
+ export function abortError(reason: unknown): Error {
142
+ if (reason instanceof Error && reason.name !== "AbortError") return reason;
143
+ const error = new Error(typeof reason === "string" ? reason : "Eval interrupted", { cause: reason });
144
+ error.name = "AbortError";
145
+ return error;
146
+ }