@code-yeongyu/senpi-codemode 2026.8.6 → 2026.8.9

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,42 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.8.9] - 2026-08-09
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Detached eval cells now publish their liveness as a `resumption_channel_state` event (source `eval-detached`) on the
22
+ host event bus: a full per-source snapshot with `activeCount` and per-cell `id`/`description`/`startedAtMs` entries is
23
+ emitted whenever a cell detaches, settles, is stopped, or is disposed, and once on `session_start`. The goal builtin
24
+ consumes this to hold its hidden continuation while detached cells are still computing instead of nagging immediately
25
+ at turn end. Hosts without an event bus are unaffected (emission is a no-op), and the footer/status rendering is
26
+ unchanged.
27
+
28
+ ### Changed
29
+
30
+ ### Fixed
31
+
32
+ ### Removed
33
+
34
+ ## [2026.8.7] - 2026-08-07
35
+
36
+ ### Breaking Changes
37
+
38
+ ### Added
39
+
40
+ ### Changed
41
+
42
+ ### Fixed
43
+
44
+ - Formatted completed eval durations in the simple-result transcript branch with the same compact human-readable units
45
+ used by detailed cell headers and nested tool widgets, so sub-second, seconds, minutes, and hours values render as
46
+ labels such as `<1s`, `12s`, `3m 5s`, or `1h 2m` instead of raw millisecond counts. Live footer, working-status, and
47
+ thinking-duration policies are unchanged ([#743](https://github.com/code-yeongyu/senpi/pull/743)).
48
+
49
+ ### Removed
50
+
15
51
  ## [2026.8.6] - 2026-08-06
16
52
 
17
53
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.8.6",
3
+ "version": "2026.8.9",
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.8.6",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.9",
34
34
  "typebox": "1.3.8"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.8.6"
37
+ "@code-yeongyu/senpi": "2026.8.9"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.8.6"
40
+ "@code-yeongyu/senpi": "2026.8.9"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resumption-channel liveness contract shared with the goal builtin.
3
+ *
4
+ * The goal builtin delays its hidden continuation while any live resumption
5
+ * channel can still wake the session. Each emitter publishes a full per-source
6
+ * snapshot on every liveness transition and once on `session_start`.
7
+ *
8
+ * The event name is duplicated here on purpose: senpi-codemode is a separate
9
+ * package and must not import across the packages/coding-agent boundary, so
10
+ * the sentinel test pins this literal to the exact cross-package contract.
11
+ */
12
+ export const RESUMPTION_CHANNEL_STATE_EVENT = "resumption_channel_state";
13
+
14
+ /** Source key identifying detached eval cells in the per-source snapshot. */
15
+ export const EVAL_DETACHED_CHANNEL_SOURCE = "eval-detached";
16
+
17
+ /** One live channel as broadcast on the resumption channel state event. */
18
+ export interface ResumptionChannelEntry {
19
+ readonly id: string;
20
+ readonly description: string;
21
+ /** Epoch milliseconds when the channel registered; lets consumers render their own elapsed labels. */
22
+ readonly startedAtMs: number;
23
+ }
24
+
25
+ export interface ResumptionChannelState {
26
+ readonly source: string;
27
+ readonly activeCount: number;
28
+ readonly channels?: readonly ResumptionChannelEntry[];
29
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { defaultCodemodeSettings } from "./config/settings.ts";
7
7
  import { EvalNotifier } from "./extension/eval-notifier.ts";
8
8
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
9
9
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
10
+ import { RESUMPTION_CHANNEL_STATE_EVENT, type ResumptionChannelState } from "./extension/resumption-channel.ts";
10
11
  import {
11
12
  createExecuteTool,
12
13
  createRuntime,
@@ -38,6 +39,8 @@ export interface CodemodeExtensionAPI {
38
39
  getActiveTools(): string[];
39
40
  getAllTools(): readonly EvalSchemaToolInfo[];
40
41
  sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
42
+ /** Optional host event bus; a host without one turns resumption-channel emission into a harmless no-op. */
43
+ events?: { emit(name: string, data: unknown): void };
41
44
  }
42
45
 
43
46
  export interface SenpiCodemodeOptions {
@@ -79,6 +82,9 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
79
82
  const showDetachedCells = (entries: readonly EvalDetachedCellStatusEntry[]): void => {
80
83
  statusTicker.sync(entries);
81
84
  };
85
+ const emitChannelState = (state: ResumptionChannelState): void => {
86
+ pi.events?.emit(RESUMPTION_CHANNEL_STATE_EVENT, state);
87
+ };
82
88
  const registerEvalForRuntime = (
83
89
  runtime: SessionRuntime,
84
90
  modelId: string | undefined,
@@ -126,6 +132,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
126
132
  cellManager: new EvalDetachedCellManager({
127
133
  notifier,
128
134
  onStatusChange: showDetachedCells,
135
+ onChannelState: emitChannelState,
129
136
  ...(options.now === undefined ? {} : { now: options.now }),
130
137
  }),
131
138
  executionTracker: manager,
@@ -156,9 +163,12 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
156
163
  artifactsDir: runtime.artifactsDir,
157
164
  notifier,
158
165
  onStatusChange: showDetachedCells,
166
+ onChannelState: emitChannelState,
159
167
  ...(options.now === undefined ? {} : { now: options.now }),
160
168
  });
161
169
  activeCells = cellManager;
170
+ // The goal builtin clears its per-session counts at session_start; re-publish our snapshot.
171
+ cellManager.publishChannelState();
162
172
  activeRuntime = runtime;
163
173
  activeModelId = ctx.model?.id;
164
174
  registerEvalForRuntime(runtime, activeModelId, cellManager);
@@ -1,4 +1,5 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import { EVAL_DETACHED_CHANNEL_SOURCE, type ResumptionChannelState } from "../extension/resumption-channel.ts";
2
3
  import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
3
4
  import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
4
5
  import {
@@ -58,12 +59,15 @@ export interface EvalDetachedCellManagerOptions {
58
59
  readonly artifactsDir?: string;
59
60
  readonly notifier?: EvalDetachedCellNotifier;
60
61
  readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
62
+ /** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
63
+ readonly onChannelState?: (state: ResumptionChannelState) => void;
61
64
  readonly now?: () => number;
62
65
  }
63
66
 
64
67
  export class EvalDetachedCellManager {
65
68
  readonly #artifactsDir: string | undefined;
66
69
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
70
+ readonly #onChannelState: ((state: ResumptionChannelState) => void) | undefined;
67
71
  readonly #cells = new Map<string, ManagedCell>();
68
72
  readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
69
73
  readonly #notificationQueue: DetachedNotificationQueue;
@@ -72,6 +76,7 @@ export class EvalDetachedCellManager {
72
76
  constructor(options: EvalDetachedCellManagerOptions = {}) {
73
77
  this.#artifactsDir = options.artifactsDir;
74
78
  this.#onStatusChange = options.onStatusChange;
79
+ this.#onChannelState = options.onChannelState;
75
80
  this.#notificationQueue = new DetachedNotificationQueue(options.notifier, options.artifactsDir);
76
81
  this.#now = options.now ?? Date.now;
77
82
  }
@@ -162,6 +167,11 @@ export class EvalDetachedCellManager {
162
167
  await this.#notificationQueue.flush();
163
168
  }
164
169
 
170
+ /** Re-publish the current snapshot; consumers reset their per-source counts at session_start. */
171
+ publishChannelState(): void {
172
+ this.#emitChannelState([...this.#detachedByLanguage.values()]);
173
+ }
174
+
165
175
  #settle(
166
176
  cell: ManagedCell,
167
177
  state: "completed" | "failed" | "cancelled",
@@ -188,14 +198,29 @@ export class EvalDetachedCellManager {
188
198
  }
189
199
 
190
200
  #emitStatus(): void {
201
+ const liveCells = [...this.#detachedByLanguage.values()];
191
202
  this.#onStatusChange?.(
192
- [...this.#detachedByLanguage.values()].map((cell) => ({
203
+ liveCells.map((cell) => ({
193
204
  cellId: cell.cellId,
194
205
  language: cell.input.language,
195
206
  startedAtMs: cell.startedAtMs,
196
207
  ...(cell.input.summary === undefined ? {} : { summary: cell.input.summary }),
197
208
  })),
198
209
  );
210
+ this.#emitChannelState(liveCells);
211
+ }
212
+
213
+ #emitChannelState(liveCells: readonly ManagedCell[]): void {
214
+ this.#onChannelState?.({
215
+ source: EVAL_DETACHED_CHANNEL_SOURCE,
216
+ activeCount: liveCells.length,
217
+ channels: liveCells.map((cell) => ({
218
+ id: cell.cellId,
219
+ description:
220
+ cell.input.summary === undefined || cell.input.summary.length === 0 ? cell.cellId : cell.input.summary,
221
+ startedAtMs: cell.startedAtMs,
222
+ })),
223
+ });
199
224
  }
200
225
 
201
226
  #snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
@@ -757,7 +757,8 @@ function resultMetadata(
757
757
  ): RenderBlock[] {
758
758
  const metadata: string[] = [];
759
759
  if (details?.phase) metadata.push(`phase ${details.phase}`);
760
- if (!options.isPartial && typeof details?.durationMs === "number") metadata.push(`took ${details.durationMs}ms`);
760
+ if (!options.isPartial && typeof details?.durationMs === "number")
761
+ metadata.push(`took ${formatDuration(details.durationMs)}`);
761
762
  if (metadata.length === 0) return [];
762
763
  return [{ kind: "text", text: style(theme, "muted", metadata.join(" | ")) }];
763
764
  }