dsh-context 0.43.0 → 0.45.0

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 (4) hide show
  1. package/lib/client.js +3605 -1765
  2. package/lib/index.d.ts +171 -24
  3. package/lib/index.js +910 -251
  4. package/package.json +6 -4
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { z } from "zod";
6
6
  import { deriveEventMessage } from "@deepseek-ai/dsh-session";
7
7
  import z$1 from "@deepseek-ai/schemastery";
8
+ import { randomUUID } from "node:crypto";
8
9
  /**
9
10
  * Recover the MCP server display label from a proxied tool name, or undefined
10
11
  * for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
@@ -273,7 +274,8 @@ const DEFAULT_BOUNDS = {
273
274
  maxKeptTurns: 300,
274
275
  maxEvents: 400,
275
276
  maxNodes: 2e3,
276
- maxArchiveNodes: 400
277
+ maxArchiveNodes: 400,
278
+ maxFileOps: 400
277
279
  };
278
280
  /**
279
281
  * The cordis `Config` validator: strict on keys, defaults on the schema fields; tolerates `undefined` (a patch row without a `config:`
@@ -284,74 +286,27 @@ const Config = z.preprocess((v) => v ?? {}, z.object({
284
286
  maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
285
287
  maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
286
288
  maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
287
- maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes)
289
+ maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes),
290
+ maxFileOps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxFileOps)
288
291
  }).strict());
289
292
  function resolveBounds(config) {
290
293
  return Config.parse(config ?? {});
291
294
  }
292
295
  //#endregion
293
- //#region src/shared/version.ts
294
- /**
295
- * The harness-version gate's shared arithmetic — the supported dsh baseline
296
- * and the version compare behind it. Runtime code shared by BOTH halves (the
297
- * host probes and gates; the client displays what the wire record carries),
298
- * so this module must stay dependency-free.
299
- *
300
- * The baseline mirrors the support matrix (AGENTS.md "Compatibility" and the
301
- * package's `dsh.compatibility.dshReleases` declaration): the oldest dsh
302
- * release this plugin works on. A harness BELOW it gets the fallback units
303
- * (host/fallback.ts) instead of the real folds.
304
- */
305
- /** The oldest supported dsh release (see the matrix note above). */
306
- const BASELINE_DSH_VERSION = "0.1.2-rc.1";
307
- /**
308
- * Release-channel rank at an equal X.Y.Z: a final release outranks its
309
- * release candidates, which outrank betas, which outrank alphas
310
- * (正式版 > RC > Beta > Alpha).
311
- */
312
- function channelRank(channel) {
313
- return channel === "rc" ? 3 : channel === "beta" ? 2 : 1;
314
- }
315
- const RELEASE_RANK = 4;
316
- /**
317
- * Parse `v?[major].[minor].[patch][-(alpha|beta|rc)[.N]][+build]`, or null
318
- * when the string is not that shape. Channels other than alpha/beta/rc
319
- * (nightly, dev, …) do not parse — the gate fails open on them.
320
- */
321
- function parseVersion(version) {
322
- const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)(?:\.(\d+))?)?(?:\+[0-9a-z.-]+)?$/i.exec(version.trim());
323
- if (match === null) return null;
324
- const channel = match[4]?.toLowerCase();
325
- return {
326
- major: Number(match[1]),
327
- minor: Number(match[2]),
328
- patch: Number(match[3]),
329
- rank: channel === void 0 ? RELEASE_RANK : channelRank(channel),
330
- serial: match[5] ? Number(match[5]) : 0
331
- };
332
- }
333
- /**
334
- * Total order over parsed versions: X.Y.Z numerically first, then the
335
- * channel rank, then the prerelease serial.
336
- */
337
- function compareParsed(a, b) {
338
- if (a.major !== b.major) return a.major - b.major;
339
- if (a.minor !== b.minor) return a.minor - b.minor;
340
- if (a.patch !== b.patch) return a.patch - b.patch;
341
- if (a.rank !== b.rank) return a.rank - b.rank;
342
- return a.serial - b.serial;
343
- }
296
+ //#region src/shared/estimate.ts
344
297
  /**
345
- * Whether `version` satisfies the supported baseline. FAIL OPEN by design: a
346
- * version that cannot be parsed (a dev/nightly harness build) must not blank
347
- * a working deployment, so it passes the gate trips only on a proven
348
- * below-baseline release.
298
+ * Token heuristics shared by the host fold and the client boundary — the
299
+ * harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts:
300
+ * ~4 chars ≈ 1 token, +4 role framing). Priced identically on both sides so a
301
+ * legacy value normalized at the client boundary matches what the host view
302
+ * would have served.
349
303
  */
350
- function meetsBaseline(version, baseline = BASELINE_DSH_VERSION) {
351
- const v = parseVersion(version);
352
- const b = parseVersion(baseline);
353
- if (v === null || b === null) return true;
354
- return compareParsed(v, b) >= 0;
304
+ const CHARS_PER_TOKEN$1 = 4;
305
+ const ROLE_OVERHEAD$1 = 4;
306
+ /** Price rendered system-prompt text; 0 for absent/empty/non-string input. */
307
+ function estimateSystemTokens(text) {
308
+ if (typeof text !== "string" || text.length === 0) return 0;
309
+ return Math.ceil(text.length / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
355
310
  }
356
311
  //#endregion
357
312
  //#region src/shared/imageTokens.ts
@@ -499,11 +454,11 @@ function estimateImageTokens(width, height) {
499
454
  * (shared/imageTokens.ts), falling back to the meter's JSON price when the
500
455
  * attachment's dimensions are unknown.
501
456
  */
502
- const CHARS_PER_TOKEN$1 = 4;
457
+ const CHARS_PER_TOKEN = 4;
503
458
  const BLOCK_OVERHEAD = 4;
504
- const ROLE_OVERHEAD$1 = 4;
459
+ const ROLE_OVERHEAD = 4;
505
460
  function estimateToolsTotal(tools) {
506
- return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN$1) + BLOCK_OVERHEAD : 0;
461
+ return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD : 0;
507
462
  }
508
463
  /** The `ContentBlock` walkers take `unknown`: block arrays ride the untrusted
509
464
  * log, so their element shapes (null and primitives included) are re-proved
@@ -520,10 +475,10 @@ function estimateBlocks(blocks) {
520
475
  switch (block.type) {
521
476
  case "text":
522
477
  case "reasoning":
523
- tokens += Math.ceil((block.text || "").length / CHARS_PER_TOKEN$1) + BLOCK_OVERHEAD;
478
+ tokens += Math.ceil((block.text || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
524
479
  break;
525
480
  case "tool-call":
526
- tokens += Math.ceil((block.name || "").length / CHARS_PER_TOKEN$1) + Math.ceil((block.arguments || "").length / CHARS_PER_TOKEN$1) + BLOCK_OVERHEAD;
481
+ tokens += Math.ceil((block.name || "").length / CHARS_PER_TOKEN) + Math.ceil((block.arguments || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
527
482
  break;
528
483
  case "tool-result":
529
484
  tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD;
@@ -531,10 +486,10 @@ function estimateBlocks(blocks) {
531
486
  case "image": {
532
487
  const ref = block.attachment;
533
488
  const priced = ref !== null && typeof ref === "object" && typeof ref.width === "number" && typeof ref.height === "number" ? estimateImageTokens(ref.width, ref.height) : null;
534
- tokens += (priced ?? Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN$1)) + BLOCK_OVERHEAD;
489
+ tokens += (priced ?? Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)) + BLOCK_OVERHEAD;
535
490
  break;
536
491
  }
537
- default: tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN$1);
492
+ default: tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
538
493
  }
539
494
  }
540
495
  return tokens;
@@ -546,12 +501,12 @@ function estimateBlocks(blocks) {
546
501
  */
547
502
  function estimateMessage(message, emptyIsZero = false) {
548
503
  if (emptyIsZero && (message === null || message === void 0 || !Array.isArray(message.content) || message.content.length === 0)) return 0;
549
- return estimateBlocks(message?.content) + ROLE_OVERHEAD$1;
504
+ return estimateBlocks(message?.content) + ROLE_OVERHEAD;
550
505
  }
551
506
  /** The shared meter heuristic over rendered system-prompt text (shared/estimate.ts). */
552
507
  /** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */
553
508
  function estimateToolSchema(tool) {
554
- return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN$1) + BLOCK_OVERHEAD;
509
+ return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
555
510
  }
556
511
  /**
557
512
  * Count image blocks in a message payload, recursing into nested content (tool-result blocks carry their inner blocks) — seeds each node's
@@ -610,157 +565,231 @@ function isInjection(source) {
610
565
  return source !== null && source !== void 0 && (typeof source.kind === "string" && source.kind !== "" && source.kind !== "user" || typeof source.form === "string");
611
566
  }
612
567
  //#endregion
613
- //#region src/shared/estimate.ts
568
+ //#region src/shared/fileOps.ts
569
+ /** Parse a call's raw JSON arguments; non-string/malformed/non-record inputs yield null. */
570
+ function parseCallArgs(raw) {
571
+ if (typeof raw !== "string" || raw === "") return null;
572
+ try {
573
+ const parsed = JSON.parse(raw);
574
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+ const KIND_BY_TOOL = {
580
+ read: "read",
581
+ read_image: "read",
582
+ write: "write",
583
+ edit: "write",
584
+ grep: "search",
585
+ glob: "search"
586
+ };
587
+ /** The file purpose of a tool, or null for non-file tools (bash, web_search…). */
588
+ function kindOfTool(tool) {
589
+ if (tool === void 0) return null;
590
+ return KIND_BY_TOOL[tool] ?? null;
591
+ }
614
592
  /**
615
- * Token heuristics shared by the host fold and the client boundary — the
616
- * harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts:
617
- * ~4 chars 1 token, +4 role framing). Priced identically on both sides so a
618
- * legacy value normalized at the client boundary matches what the host view
619
- * would have served.
593
+ * The file purpose of one executed call. Like {@link kindOfTool} except for
594
+ * the one file tool whose purpose follows its arguments: `str_replace_editor`
595
+ * reads on `view` and writes on every other command (create / str_replace /
596
+ * insert an unknown command writes too; the call failed and the row keeps
597
+ * its error flag).
620
598
  */
621
- const CHARS_PER_TOKEN = 4;
622
- const ROLE_OVERHEAD = 4;
623
- /** Price rendered system-prompt text; 0 for absent/empty/non-string input. */
624
- function estimateSystemTokens(text) {
625
- if (typeof text !== "string" || text.length === 0) return 0;
626
- return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD;
599
+ function kindOfCall(tool, args) {
600
+ if (tool === "str_replace_editor") return args !== null && args.command === "view" ? "read" : "write";
601
+ return kindOfTool(tool);
627
602
  }
628
- //#endregion
629
- //#region src/host/headers.ts
630
603
  /**
631
- * The `contextHeaders` session projection unit — the request-header EPOCH
632
- * METADATA behind the timeline's envelope figures.
633
- *
634
- * The hot `contextTimeline` unit carries only token prices of the system
635
- * prompt and tool schemas; this companion unit keeps the per-epoch METADATA
636
- * (epoch seq/time boundaries, per-tool token prices and plugin attribution)
637
- * so the Context browser can pick the header epoch in force at any step and
638
- * size its sections immediately. The epoch CONTENT (full system prompt text,
639
- * full tool JSON schemas) deliberately does NOT ride the projection VALUE:
640
- * session projections are served whole in every `session.list` row, control
641
- * baseline, push frame, and change notification, so carrying content here
642
- * multiplied it by sessions × epochs across every channel. The client
643
- * fetches one epoch's `request/header` event on demand — a seq-anchored
644
- * history read off the epoch's `seq`, the same targeted read the browser
645
- * already uses for message content — and caches it per session (history is
646
- * immutable).
647
- *
648
- * Read-compat over the persisted state (the pinned decision behind keeping
649
- * `stateVersion` at 1): the harness serves a cold session's projections from
650
- * its CACHED checkpoint rows and has no refresh channel for an idle session
651
- * — a version bump invalidates every row and orphans the key until the
652
- * session goes live again (the #37 regression). The state therefore still
653
- * ACCEPTS the v1 content-bearing record shape, current folds append
654
- * metadata-only records alongside any seeded legacy ones, and the view
655
- * normalizes BOTH to the metadata-only wire shape (pricing the legacy system
656
- * text at read time). Cached v1 rows keep working, new checkpoint writes
657
- * shrink as legacy epochs age out of the capped list, and the wire — the
658
- * part every delivery channel carries — is metadata-only from day one.
659
- *
660
- * Same projection contract as the timeline unit: pure init/apply/view,
661
- * `Object.is` reference stability for uninteresting events, plain-JSON
662
- * bounded state (epoch list capped — see HEADERS_MAX).
604
+ * The operation's target path: the path-ish argument of read/write tools;
605
+ * for searches the narrowing `path`, else the pattern itself (a pathless
606
+ * grep/glob's target IS the pattern — the workspace-wide search text).
663
607
  */
664
- /** Retention cap on header epochs (metadata only; changes are rare; 50 is generous). */
665
- const HEADERS_MAX = 50;
608
+ function pathOfArgs(tool, args) {
609
+ if (args === null) return null;
610
+ if (tool === "grep" || tool === "glob") {
611
+ const p = args.path;
612
+ if (typeof p === "string" && p !== "") return p;
613
+ const pattern = args.pattern;
614
+ return typeof pattern === "string" && pattern !== "" ? pattern : null;
615
+ }
616
+ for (const k of [
617
+ "file_path",
618
+ "filePath",
619
+ "path"
620
+ ]) {
621
+ const v = args[k];
622
+ if (typeof v === "string" && v !== "") return v;
623
+ }
624
+ return null;
625
+ }
626
+ /** Rendered line count: '' is 0, a trailing newline closes its own line. */
627
+ function linesOf(s) {
628
+ if (s === "") return 0;
629
+ let n = 0;
630
+ for (let i = 0; i < s.length; i++) if (s[i] === "\n") n++;
631
+ return s.endsWith("\n") ? n : n + 1;
632
+ }
633
+ /** The added/removed pair of one content-bearing argument set, or zeros. */
634
+ function pairOf(added, removed) {
635
+ return {
636
+ added: typeof added === "string" ? linesOf(added) : 0,
637
+ removed: typeof removed === "string" ? linesOf(removed) : 0
638
+ };
639
+ }
666
640
  /**
667
- * The persisted-state schema: the SUPERSET of both record generations, so a
668
- * cached v1 row (content-bearing) seeds the fold instead of being discarded.
641
+ * The signed line footprint of one call: an edit removes its old string and
642
+ * adds its new one; a write adds its content (the pre-existing body, if any,
643
+ * is unknowable from the arguments — the estimate stays honest about that);
644
+ * `str_replace_editor` splits the same shapes across its commands. Callers
645
+ * reach here only with parsed args (a null parse yields no path).
669
646
  */
670
- const storedToolSchema = z.object({
671
- name: z.string(),
672
- tokens: z.number().int().nonnegative(),
673
- description: z.string().optional(),
674
- plugin: z.string().optional(),
675
- schema: z.unknown().optional()
676
- }).strict();
677
- const storedEpochSchema = z.object({
678
- seq: z.number(),
679
- time: z.number(),
680
- system: z.string().optional(),
681
- systemTokens: z.number().int().nonnegative().optional(),
682
- tools: z.array(storedToolSchema)
683
- }).strict();
684
- const contextHeadersStateSchema = z.object({ headers: z.array(storedEpochSchema) }).strict();
685
- /** The wire schema: strict metadata — the shape every delivery channel carries. */
686
- const headerToolWireSchema = z.object({
687
- name: z.string(),
688
- tokens: z.number().int().nonnegative(),
689
- plugin: z.string().optional()
690
- }).strict();
691
- /** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
692
- const contextHeadersSchema = z.object({ headers: z.array(z.object({
693
- seq: z.number(),
694
- time: z.number(),
695
- systemTokens: z.number().int().nonnegative().optional(),
696
- tools: z.array(headerToolWireSchema)
697
- }).strict()) }).strict();
698
- function recordOf(event) {
699
- if (event.type !== "request/header") return null;
700
- const rawHeader = event.data.header;
701
- if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
702
- const header = rawHeader;
703
- const tools = Array.isArray(header.tools) ? header.tools : [];
704
- const record = {
705
- seq: event.seq,
706
- time: event.time,
707
- tools: tools.map((t) => {
708
- const tool = t !== null && typeof t === "object" ? t : {};
709
- const entry = {
710
- name: typeof tool.name === "string" ? tool.name : "?",
711
- tokens: estimateToolSchema(t)
712
- };
713
- if (typeof tool.plugin === "string" && tool.plugin !== "") entry.plugin = tool.plugin;
714
- return entry;
715
- })
647
+ function deltaOf(tool, args) {
648
+ if (tool === "edit") return pairOf(args.new_string, args.old_string);
649
+ if (tool === "write") return pairOf(args.content, void 0);
650
+ if (tool === "str_replace_editor") {
651
+ if (args.command === "str_replace") return pairOf(args.new_str, args.old_str);
652
+ if (args.command === "insert") return pairOf(args.new_str, void 0);
653
+ if (args.command === "create") return pairOf(args.file_text, void 0);
654
+ }
655
+ return {
656
+ added: 0,
657
+ removed: 0
716
658
  };
717
- if (typeof header.system === "string" && header.system.length > 0) record.systemTokens = estimateSystemTokens(header.system);
718
- return record;
659
+ }
660
+ /** The exact window a read's result meta reports: `offset` plus the retained
661
+ * `lines` array (the same bounded payload the read card renders from). Null
662
+ * for a foreign or malformed meta. */
663
+ function readWindowOf(meta) {
664
+ if (meta === null || typeof meta !== "object") return null;
665
+ const m = meta;
666
+ if (typeof m.path !== "string" || m.path === "") return null;
667
+ if (typeof m.offset !== "number" || !Number.isFinite(m.offset) || m.offset < 1) return null;
668
+ if (!Array.isArray(m.lines) || m.lines.length === 0) return null;
669
+ return {
670
+ start: m.offset,
671
+ count: m.lines.length
672
+ };
673
+ }
674
+ /** The limit estimate: the tool reads up to `limit` lines from `offset`;
675
+ * absent when the call reads unbounded. */
676
+ function readEstimateOf(args) {
677
+ const limit = args.limit;
678
+ return typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? {
679
+ count: Math.floor(limit),
680
+ est: true
681
+ } : void 0;
682
+ }
683
+ /** What a read op shows: the exact window off the result meta, else the
684
+ * limit estimate, else nothing (an unbounded read names no footprint). */
685
+ function readOf(meta, args) {
686
+ const win = readWindowOf(meta);
687
+ if (win !== null) return {
688
+ start: win.start,
689
+ count: win.count
690
+ };
691
+ return readEstimateOf(args);
719
692
  }
720
693
  /**
721
- * The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
722
- * `useProjection('contextHeaders')` and fetch an epoch's full content on demand via the session history (historyPage.ts).
723
- * Contract mirror with a REQUIRED `wire` block (see compat.ts).
724
- * @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
725
- * epochs folded without attribution still render a tag when the source is known.
694
+ * A search op's detail: the pattern, with the include filter appended when
695
+ * one narrowed the call. A patternless (malformed) search has no detail.
726
696
  */
727
- function createContextHeadersDefinition(resolve) {
728
- const view = (state) => ({ headers: state.headers.map((h) => {
729
- const record = {
730
- seq: h.seq,
731
- time: h.time,
732
- tools: h.tools.map((t) => {
733
- const entry = {
734
- name: t.name,
735
- tokens: t.tokens
736
- };
737
- const plugin = t.plugin ?? (resolve !== void 0 ? resolve(t.name) : void 0);
738
- if (plugin !== void 0) entry.plugin = plugin;
739
- return entry;
740
- })
741
- };
742
- const systemTokens = h.systemTokens ?? (typeof h.system === "string" && h.system !== "" ? estimateSystemTokens(h.system) : void 0);
743
- if (systemTokens !== void 0) record.systemTokens = systemTokens;
744
- return record;
745
- }) });
746
- return {
747
- key: "contextHeaders",
748
- stateSchema: contextHeadersStateSchema,
749
- wire: {
750
- viewSchema: contextHeadersSchema,
751
- view
752
- },
753
- init: () => ({ headers: [] }),
754
- apply: (state, event) => {
755
- const record = recordOf(event);
756
- if (record === null) return state;
757
- const last = state.headers.at(-1);
758
- if (last !== void 0 && last.seq === record.seq) return state;
759
- const headers = [...state.headers, record];
760
- return { headers: headers.length > HEADERS_MAX ? headers.slice(-50) : headers };
761
- },
762
- stateVersion: 1
697
+ function searchDetailOf(args) {
698
+ const pattern = args?.pattern;
699
+ if (typeof pattern !== "string" || pattern === "") return void 0;
700
+ const include = args?.include;
701
+ return typeof include === "string" && include !== "" ? `${pattern} (${include})` : pattern;
702
+ }
703
+ /**
704
+ * The files a search demonstrably reached, read off the result's bounded
705
+ * presentation meta (grep groups matched lines by file; glob lists paths).
706
+ * Only the COMPLETE list attributes: a capped search (`truncated`) names a
707
+ * partial file set, and a malformed meta names none both fall back to the
708
+ * call's own target. Each entry carries the reported match count.
709
+ */
710
+ function searchFilesOf(meta) {
711
+ if (meta === null || typeof meta !== "object") return null;
712
+ const m = meta;
713
+ if (m.truncated !== false) return null;
714
+ const files = [];
715
+ if (m.shape === "matches" && Array.isArray(m.files)) for (const f of m.files) {
716
+ if (f === null || typeof f !== "object") continue;
717
+ const group = f;
718
+ if (typeof group.path === "string" && group.path !== "" && Array.isArray(group.matches)) files.push({
719
+ path: group.path,
720
+ hits: group.matches.length
721
+ });
722
+ }
723
+ else if (m.shape === "paths" && Array.isArray(m.paths)) {
724
+ for (const p of m.paths) if (typeof p === "string" && p !== "") files.push({
725
+ path: p,
726
+ hits: 0
727
+ });
728
+ }
729
+ return files.length > 0 ? files : null;
730
+ }
731
+ /**
732
+ * The one-shot per-call op assembly, uniform across every producer: the
733
+ * host's call/result pairing (args off the armed call, meta off the
734
+ * result), the nested Code-Mode settle (no meta exists on a dispatch — the
735
+ * read window and per-file search attribution degrade to the argument-only
736
+ * forms), and the client's inline-generation join fallback. Returns zero to
737
+ * N records: a search with the complete matched-file meta rows per file;
738
+ * any other file call rows once; a non-file tool (or a call whose arguments
739
+ * resolve no target) rows nothing.
740
+ */
741
+ function opsOfCall(input) {
742
+ const args = parseCallArgs(input.argsRaw);
743
+ const kind = kindOfCall(input.tool, args);
744
+ if (kind === null) return [];
745
+ const stamp = {
746
+ seq: input.seq,
747
+ kind,
748
+ tool: input.tool,
749
+ err: input.err === true,
750
+ added: 0,
751
+ removed: 0,
752
+ path: "",
753
+ ...input.time !== void 0 ? { time: input.time } : {},
754
+ ...input.gone !== void 0 ? { gone: input.gone } : {},
755
+ ...input.parent !== void 0 ? { parent: input.parent } : {},
756
+ ...input.program !== void 0 ? { program: input.program } : {}
763
757
  };
758
+ if (kind === "search") {
759
+ const files = searchFilesOf(input.meta);
760
+ if (files !== null) {
761
+ const detail = searchDetailOf(args);
762
+ const target = args !== null ? pathOfArgs(input.tool, args) : null;
763
+ const narrowed = args !== null && typeof args.path === "string" && args.path !== "";
764
+ return [...target !== null && !files.some((f) => f.path === target) ? [{
765
+ ...stamp,
766
+ path: target,
767
+ ...narrowed && detail !== void 0 ? { detail } : {},
768
+ ...narrowed ? {} : { pattern: true }
769
+ }] : [], ...files.map((f) => ({
770
+ ...stamp,
771
+ path: f.path,
772
+ ...detail !== void 0 ? { detail } : {},
773
+ ...f.hits > 0 ? { hits: f.hits } : {}
774
+ }))];
775
+ }
776
+ }
777
+ if (args === null) return [];
778
+ const path = pathOfArgs(input.tool, args);
779
+ if (path === null) return [];
780
+ const { added, removed } = deltaOf(input.tool, args);
781
+ const narrowed = typeof args.path === "string" && args.path !== "";
782
+ const detail = kind === "search" && narrowed ? searchDetailOf(args) : void 0;
783
+ const read = kind === "read" ? readOf(input.meta, args) : void 0;
784
+ return [{
785
+ ...stamp,
786
+ path,
787
+ added,
788
+ removed,
789
+ ...detail !== void 0 ? { detail } : {},
790
+ ...read !== void 0 ? { read } : {},
791
+ ...kind === "search" && !narrowed ? { pattern: true } : {}
792
+ }];
764
793
  }
765
794
  //#endregion
766
795
  //#region src/host/fold.ts
@@ -792,6 +821,11 @@ function trimState(st, bounds) {
792
821
  if (countTurnRuns(st.requests) > bounds.maxKeptTurns) st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
793
822
  if (st.requests.length > bounds.maxRequestSteps) st.requests = st.requests.slice(-bounds.maxRequestSteps);
794
823
  if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents);
824
+ if (st.fileOps.length > bounds.maxFileOps) {
825
+ const drop = st.fileOps.length - bounds.maxFileOps;
826
+ st.fileOpsFloor = Math.max(st.fileOpsFloor ?? 0, st.fileOps[drop - 1].seq);
827
+ st.fileOps = st.fileOps.slice(drop);
828
+ }
795
829
  if (st.archived.length > 0) {
796
830
  let drop = 0;
797
831
  const oldestReq = st.requests.length > 0 ? st.requests[0].seq : void 0;
@@ -818,7 +852,8 @@ function createTimelineState() {
818
852
  requests: [],
819
853
  events: [],
820
854
  archived: [],
821
- callNames: {}
855
+ callNames: {},
856
+ fileOps: []
822
857
  };
823
858
  }
824
859
  function categoryOf(type, message) {
@@ -828,13 +863,58 @@ function categoryOf(type, message) {
828
863
  return "user";
829
864
  }
830
865
  /**
831
- * Archive removed surface nodes as stamped COPIES — the objects leaving
832
- * `st.surface` are shared with the persisted previous state, so `gone` must
833
- * never be written onto them directly.
866
+ * Mark the detail collections dirty (TimelineState.detailRev). Every caller
867
+ * is a fold branch that just mutated the requests/events/surface/archive;
868
+ * branches that touch only the working slots (stepStart, callNames, the
869
+ * shadow claim) or the envelope scalars do NOT bump — the served detail is
870
+ * unchanged, and an open tab has nothing to refetch.
834
871
  */
835
- function archiveRemoved(st, removed, goneSeq) {
836
- for (const n of removed) st.archived.push({
837
- ...n,
872
+ function bumpDetailRev(st) {
873
+ st.detailRev = (st.detailRev ?? 0) + 1;
874
+ }
875
+ /**
876
+ * Bound on the buffered nested Code-Mode ops (TimelineState.pendingCodeOps)
877
+ * — a hostile log that dispatches without settling the parent run_code
878
+ * cannot grow the persisted state past this.
879
+ */
880
+ const PENDING_CODE_OPS_MAX = 200;
881
+ /** JSON-stringify an unknown argument payload; a hostile (cyclic) value yields no args. */
882
+ function argsRawOf(value) {
883
+ if (typeof value === "string") return value;
884
+ if (value === void 0 || value === null) return void 0;
885
+ try {
886
+ return JSON.stringify(value);
887
+ } catch {
888
+ return;
889
+ }
890
+ }
891
+ /** Append op records to the fold-derived log (the trim lives in trimState, with the other collections). */
892
+ function pushFileOps(st, ops) {
893
+ for (const op of ops) st.fileOps.push(op);
894
+ }
895
+ /**
896
+ * Buffer nested Code-Mode ops under their top run_code call id (they flush
897
+ * when the parent's result folds — the ops' locate target). A full buffer
898
+ * drops new arrivals wholesale (defensive logs only).
899
+ */
900
+ function bufferCodeOps(st, rootCallId, ops) {
901
+ const pending = st.pendingCodeOps ?? {};
902
+ let total = 0;
903
+ for (const k in pending) total += pending[k].length;
904
+ if (total + ops.length > PENDING_CODE_OPS_MAX) return;
905
+ st.pendingCodeOps = {
906
+ ...pending,
907
+ [rootCallId]: [...pending[rootCallId] ?? [], ...ops]
908
+ };
909
+ }
910
+ /**
911
+ * Archive removed surface nodes as stamped COPIES — the objects leaving
912
+ * `st.surface` are shared with the persisted previous state, so `gone` must
913
+ * never be written onto them directly.
914
+ */
915
+ function archiveRemoved(st, removed, goneSeq) {
916
+ for (const n of removed) st.archived.push({
917
+ ...n,
838
918
  gone: goneSeq
839
919
  });
840
920
  }
@@ -1130,6 +1210,8 @@ function applyTimeline(state, event, bounds) {
1130
1210
  events: [...state.events],
1131
1211
  archived: [...state.archived],
1132
1212
  callNames: { ...state.callNames },
1213
+ fileOps: [...state.fileOps],
1214
+ ...state.pendingCodeOps !== void 0 ? { pendingCodeOps: { ...state.pendingCodeOps } } : {},
1133
1215
  ...state.timing !== void 0 ? { timing: {
1134
1216
  ...state.timing,
1135
1217
  tools: { ...state.timing.tools }
@@ -1146,13 +1228,16 @@ function applyTimeline(state, event, bounds) {
1146
1228
  s.systemTokens = estimateSystemTokens(header.system);
1147
1229
  if (header.config && typeof header.config.model === "string") s.model = header.config.model;
1148
1230
  if (header.config && typeof header.config.provider === "string") s.provider = header.config.provider;
1149
- if ((data?.reason === "change" || data?.reason === "resume") && s.model && s.lastModel && s.model !== s.lastModel) s.events.push({
1150
- seq: event.seq,
1151
- time: event.time,
1152
- kind: "model",
1153
- from: s.lastModel,
1154
- to: s.model
1155
- });
1231
+ if ((data?.reason === "change" || data?.reason === "resume") && s.model && s.lastModel && s.model !== s.lastModel) {
1232
+ s.events.push({
1233
+ seq: event.seq,
1234
+ time: event.time,
1235
+ kind: "model",
1236
+ from: s.lastModel,
1237
+ to: s.model
1238
+ });
1239
+ bumpDetailRev(s);
1240
+ }
1156
1241
  if (s.model) s.lastModel = s.model;
1157
1242
  break;
1158
1243
  }
@@ -1166,12 +1251,29 @@ function applyTimeline(state, event, bounds) {
1166
1251
  case "tool/call":
1167
1252
  if (data && typeof data.callId === "string" && typeof data.name === "string") {
1168
1253
  const s = ensure();
1254
+ const argsRaw = argsRawOf(data.arguments);
1169
1255
  s.callNames[data.callId] = {
1170
1256
  name: data.name,
1171
- start: event.time
1257
+ start: event.time,
1258
+ ...argsRaw !== void 0 ? { argsRaw } : {}
1172
1259
  };
1173
1260
  }
1174
1261
  break;
1262
+ case "tool/code-dispatch": {
1263
+ const rootCallId = data?.rootCallId;
1264
+ const name = data?.name;
1265
+ if (typeof rootCallId === "string" && typeof name === "string") {
1266
+ const ops = opsOfCall({
1267
+ seq: event.seq,
1268
+ time: event.time,
1269
+ tool: name,
1270
+ argsRaw: argsRawOf(data?.arguments),
1271
+ err: data?.isError === true
1272
+ });
1273
+ if (ops.length > 0) bufferCodeOps(ensure(), rootCallId, ops);
1274
+ }
1275
+ break;
1276
+ }
1175
1277
  case "assistant/chunk": {
1176
1278
  const start = state.stepStart;
1177
1279
  if (start === void 0 || start.firstToken !== void 0) return state;
@@ -1199,6 +1301,7 @@ function applyTimeline(state, event, bounds) {
1199
1301
  case "user/message": {
1200
1302
  const msg = deriveEventMessage(event);
1201
1303
  const s = ensure();
1304
+ bumpDetailRev(s);
1202
1305
  const node = applySurface(s, event, event.type, data, msg);
1203
1306
  const source = msg?.source;
1204
1307
  if (isInjection(source)) {
@@ -1223,8 +1326,34 @@ function applyTimeline(state, event, bounds) {
1223
1326
  }
1224
1327
  case "tool/result": {
1225
1328
  const toolMsg = deriveEventMessage(event);
1329
+ const srcId = (toolMsg?.source)?.callId;
1330
+ const firstBlock = toolMsg?.content?.[0];
1331
+ const blockId = firstBlock?.toolCallId;
1332
+ const pendingEntry = (typeof srcId === "string" ? state.callNames[srcId] : void 0) ?? (typeof blockId === "string" ? state.callNames[blockId] : void 0);
1333
+ const buffered = (typeof srcId === "string" ? state.pendingCodeOps?.[srcId] : void 0) ?? (typeof blockId === "string" ? state.pendingCodeOps?.[blockId] : void 0);
1226
1334
  const s = ensure();
1335
+ bumpDetailRev(s);
1227
1336
  const node = applySurface(s, event, event.type, data, toolMsg);
1337
+ if (pendingEntry !== void 0) pushFileOps(s, opsOfCall({
1338
+ seq: event.seq,
1339
+ time: event.time,
1340
+ tool: pendingEntry.name,
1341
+ argsRaw: pendingEntry.argsRaw,
1342
+ meta: data?.meta,
1343
+ err: Boolean(data?.error) || firstBlock?.isError === true
1344
+ }));
1345
+ if (buffered !== void 0 && buffered.length > 0) {
1346
+ const program = parseCallArgs(pendingEntry?.argsRaw)?.description;
1347
+ pushFileOps(s, buffered.map((op) => ({
1348
+ ...op,
1349
+ parent: event.seq,
1350
+ ...typeof program === "string" && program !== "" ? { program } : {}
1351
+ })));
1352
+ const kept = {};
1353
+ for (const k in s.pendingCodeOps) if (k !== srcId && k !== blockId) kept[k] = s.pendingCodeOps[k];
1354
+ if (Object.keys(kept).length > 0) s.pendingCodeOps = kept;
1355
+ else delete s.pendingCodeOps;
1356
+ }
1228
1357
  if (node.tool === "skill" || node.tool === void 0) {
1229
1358
  const name = skillNameOf(toolMsg);
1230
1359
  if (name !== "") {
@@ -1245,6 +1374,7 @@ function applyTimeline(state, event, bounds) {
1245
1374
  case "assistant/message": {
1246
1375
  const usage = data?.usage;
1247
1376
  const s = ensure();
1377
+ bumpDetailRev(s);
1248
1378
  const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.assistant + s.sums.tool;
1249
1379
  const record = {
1250
1380
  time: event.time,
@@ -1289,16 +1419,21 @@ function applyTimeline(state, event, bounds) {
1289
1419
  break;
1290
1420
  }
1291
1421
  case "plan/mode":
1292
- if (data && typeof data.active === "boolean") ensure().events.push({
1293
- seq: event.seq,
1294
- time: event.time,
1295
- kind: "mode",
1296
- name: data.active ? "plan.on" : "plan.off"
1297
- });
1422
+ if (data && typeof data.active === "boolean") {
1423
+ const s = ensure();
1424
+ s.events.push({
1425
+ seq: event.seq,
1426
+ time: event.time,
1427
+ kind: "mode",
1428
+ name: data.active ? "plan.on" : "plan.off"
1429
+ });
1430
+ bumpDetailRev(s);
1431
+ }
1298
1432
  break;
1299
1433
  case "compaction/summary":
1300
1434
  case "compaction/prune": {
1301
1435
  const s = ensure();
1436
+ bumpDetailRev(s);
1302
1437
  if (data && Array.isArray(data.shadowedSeqs)) {
1303
1438
  s.pendingShadowedSeqs = data.shadowedSeqs.filter((x) => typeof x === "number");
1304
1439
  s.pendingShadowEventSeq = event.seq;
@@ -1324,11 +1459,15 @@ function applyTimeline(state, event, bounds) {
1324
1459
  return state;
1325
1460
  }
1326
1461
  /**
1327
- * Serve the projection's wire view: bound the surface nodes to the newest tail and attach each event to the request around it; stamp
1328
- * COPIES
1329
- * — the persisted state objects are never mutated.
1462
+ * The envelope scalars both wire generations share: current composition, the
1463
+ * live-surface counters, and the copied cost/timing totals. Served value
1464
+ * fields are COPIES — the served value must never alias persisted state.
1465
+ * Optional scalars use conditional spread: an unknown value must not
1466
+ * materialize an `undefined`-valued property (the lossless-JSON pipeline —
1467
+ * a single such property can fail the whole push, the failure mode behind
1468
+ * issue #29).
1330
1469
  */
1331
- function buildTimelineView(state, bounds) {
1470
+ function headFieldsOf(state) {
1332
1471
  const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool;
1333
1472
  const result = {
1334
1473
  ok: true,
@@ -1346,11 +1485,11 @@ function buildTimelineView(state, bounds) {
1346
1485
  },
1347
1486
  images: state.surface.reduce((n, node) => n + (node.imgs ?? 0), 0),
1348
1487
  toolCalls: state.surface.reduce((n, node) => node.cat === "tool" ? n + 1 : n, 0),
1349
- requests: state.requests.map((r) => ({ ...r })),
1350
- events: state.events.map((e) => ({ ...e })),
1488
+ requests: [],
1489
+ events: [],
1351
1490
  nodes: [],
1352
1491
  droppedNodes: 0,
1353
- archive: state.archived.map((n) => ({ ...n }))
1492
+ archive: []
1354
1493
  };
1355
1494
  if (state.cost !== void 0) {
1356
1495
  const copyFam = (f) => {
@@ -1375,6 +1514,25 @@ function buildTimelineView(state, bounds) {
1375
1514
  tools
1376
1515
  };
1377
1516
  }
1517
+ return result;
1518
+ }
1519
+ /**
1520
+ * The heavy collections: copies of the retained request records and context
1521
+ * events (each event attached to the requests around it — the chart's ✂
1522
+ * anchoring), the bounded served surface window, and the removed-node
1523
+ * archive. Shared verbatim by the inline wire view (channel-less hosts) and
1524
+ * the on-demand detail payload (host/detail.ts).
1525
+ */
1526
+ function detailCollectionsOf(state, bounds) {
1527
+ const result = {
1528
+ requests: state.requests.map((r) => ({ ...r })),
1529
+ events: state.events.map((e) => ({ ...e })),
1530
+ nodes: [],
1531
+ droppedNodes: 0,
1532
+ archive: state.archived.map((n) => ({ ...n })),
1533
+ fileOps: state.fileOps.map((o) => ({ ...o })),
1534
+ ...state.fileOpsFloor !== void 0 ? { fileOpsFloor: state.fileOpsFloor } : {}
1535
+ };
1378
1536
  const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
1379
1537
  const overflow = state.surface.slice(0, overflowCount);
1380
1538
  const tail = state.surface.slice(overflowCount);
@@ -1405,6 +1563,354 @@ function buildTimelineView(state, bounds) {
1405
1563
  }
1406
1564
  return result;
1407
1565
  }
1566
+ /**
1567
+ * The split generation's SLIM wire head: the envelope scalars plus the
1568
+ * precomputed count figures, the newest request's billing summary (the
1569
+ * headline's derived anchor), and the detail revision marker. Small enough
1570
+ * to ride every delivery channel whole (~1KB) — the heavy collections moved
1571
+ * to the on-demand detail channel (host/detail.ts).
1572
+ */
1573
+ function buildTimelineHead(state) {
1574
+ const result = headFieldsOf(state);
1575
+ const turns = /* @__PURE__ */ new Set();
1576
+ for (const r of state.requests) turns.add(r.turn ?? 0);
1577
+ let injects = 0;
1578
+ let compactions = 0;
1579
+ let prunes = 0;
1580
+ for (const e of state.events) if (e.kind === "inject") injects++;
1581
+ else if (e.kind === "compaction") compactions++;
1582
+ else if (e.kind === "prune") prunes++;
1583
+ result.counts = {
1584
+ turns: turns.size,
1585
+ steps: state.requests.length,
1586
+ injects,
1587
+ compactions,
1588
+ prunes
1589
+ };
1590
+ const last = state.requests.at(-1);
1591
+ if (last !== void 0) result.last = {
1592
+ seq: last.seq,
1593
+ total: last.total,
1594
+ ...typeof last.prompt === "number" ? { prompt: last.prompt } : {}
1595
+ };
1596
+ result.detailRev = state.detailRev ?? 0;
1597
+ return result;
1598
+ }
1599
+ /**
1600
+ * The on-demand detail payload (host/detail.ts serves it off the live fold
1601
+ * state): the heavy collections plus the revision marker the head carries.
1602
+ */
1603
+ function buildTimelineDetail(state, bounds) {
1604
+ return {
1605
+ rev: state.detailRev ?? 0,
1606
+ ...detailCollectionsOf(state, bounds)
1607
+ };
1608
+ }
1609
+ /**
1610
+ * Serve the INLINE projection wire view (channel-less hosts): the head
1611
+ * scalars with the detail collections in place — the shape every delivery
1612
+ * channel carried before the split generation. Bound the surface nodes to
1613
+ * the newest tail and attach each event to the request around it; stamp
1614
+ * COPIES — the persisted state objects are never mutated.
1615
+ */
1616
+ function buildTimelineView(state, bounds) {
1617
+ return {
1618
+ ...headFieldsOf(state),
1619
+ ...detailCollectionsOf(state, bounds)
1620
+ };
1621
+ }
1622
+ //#endregion
1623
+ //#region src/host/detail.ts
1624
+ /** The plugin's generic Connection RPC channel (the pre-v0.9 name, kept). */
1625
+ const DETAIL_CHANNEL = "/dsh-context";
1626
+ /** The RPC failure envelope the transport expects (ConnectionRpcFailure). */
1627
+ function failure(code, message) {
1628
+ return {
1629
+ ok: false,
1630
+ error: {
1631
+ code,
1632
+ message,
1633
+ details: {}
1634
+ }
1635
+ };
1636
+ }
1637
+ /**
1638
+ * Arm the detail endpoint whenever the connection and sessions services are
1639
+ * both composed (see the module header for the load-order contract). The
1640
+ * registration rides the injected fiber: either service unloading withdraws
1641
+ * the channel and closes the gate.
1642
+ */
1643
+ function watchDetailChannel(ctx, bounds) {
1644
+ const gate = { live: false };
1645
+ ctx.inject(["connection", "sessions"], (c) => {
1646
+ const connection = c.get("connection");
1647
+ const sessions = c.get("sessions");
1648
+ const handle = typeof connection?.rpc?.handle === "function" ? connection.rpc.handle.bind(connection.rpc) : void 0;
1649
+ const getSession = typeof sessions?.get === "function" ? sessions.get.bind(sessions) : void 0;
1650
+ if (handle === void 0 || getSession === void 0) return;
1651
+ const projections = ctx.sessionProjections;
1652
+ const handler = async (endpoint, payload) => {
1653
+ if (endpoint !== "detail") return failure("dsh-context/unknown-endpoint", `unknown endpoint: ${endpoint}`);
1654
+ const sessionId = payload !== null && typeof payload === "object" ? payload.sessionId : void 0;
1655
+ if (typeof sessionId !== "string" || sessionId === "") return failure("dsh-context/bad-request", "missing sessionId");
1656
+ try {
1657
+ const session = getSession(sessionId);
1658
+ if (session !== void 0 && session !== null) {
1659
+ const state = projections.stateOf(session, "contextTimeline");
1660
+ if (state === void 0) return {
1661
+ ok: true,
1662
+ value: null
1663
+ };
1664
+ return {
1665
+ ok: true,
1666
+ value: buildTimelineDetail(state, bounds)
1667
+ };
1668
+ }
1669
+ const query = ctx.get("sessionQuery");
1670
+ const observe = typeof query?.observeSession === "function" ? query.observeSession.bind(query) : void 0;
1671
+ if (observe === void 0) return {
1672
+ ok: true,
1673
+ value: null
1674
+ };
1675
+ const observation = await observe(sessionId, { projectionMode: "none" });
1676
+ const events = observation?.events;
1677
+ if (!Array.isArray(events)) return {
1678
+ ok: true,
1679
+ value: null
1680
+ };
1681
+ let state = createTimelineState();
1682
+ try {
1683
+ for (const ev of events) state = applyTimeline(state, ev, bounds);
1684
+ } finally {
1685
+ const dispose = observation?.[Symbol.dispose];
1686
+ if (typeof dispose === "function") dispose.call(observation);
1687
+ }
1688
+ return {
1689
+ ok: true,
1690
+ value: buildTimelineDetail(state, bounds)
1691
+ };
1692
+ } catch (err) {
1693
+ return failure("gateway/internal", err instanceof Error ? err.message : String(err));
1694
+ }
1695
+ };
1696
+ try {
1697
+ c.effect(() => {
1698
+ const unregister = handle(DETAIL_CHANNEL, handler);
1699
+ return () => {
1700
+ unregister();
1701
+ };
1702
+ }, "dsh-context: detail channel");
1703
+ } catch {
1704
+ return;
1705
+ }
1706
+ gate.live = true;
1707
+ return () => {
1708
+ gate.live = false;
1709
+ };
1710
+ });
1711
+ return gate;
1712
+ }
1713
+ //#endregion
1714
+ //#region src/shared/version.ts
1715
+ /**
1716
+ * The harness-version gate's shared arithmetic — the supported dsh baseline
1717
+ * and the version compare behind it. Runtime code shared by BOTH halves (the
1718
+ * host probes and gates; the client displays what the wire record carries),
1719
+ * so this module must stay dependency-free.
1720
+ *
1721
+ * The baseline mirrors the support matrix (AGENTS.md "Compatibility" and the
1722
+ * package's `dsh.compatibility.dshReleases` declaration): the oldest dsh
1723
+ * release this plugin works on. A harness BELOW it gets the fallback units
1724
+ * (host/fallback.ts) instead of the real folds.
1725
+ */
1726
+ /** The oldest supported dsh release (see the matrix note above). */
1727
+ const BASELINE_DSH_VERSION = "0.1.2-rc.1";
1728
+ /**
1729
+ * Release-channel rank at an equal X.Y.Z: a final release outranks its
1730
+ * release candidates, which outrank betas, which outrank alphas
1731
+ * (正式版 > RC > Beta > Alpha).
1732
+ */
1733
+ function channelRank(channel) {
1734
+ return channel === "rc" ? 3 : channel === "beta" ? 2 : 1;
1735
+ }
1736
+ const RELEASE_RANK = 4;
1737
+ /**
1738
+ * Parse `v?[major].[minor].[patch][-(alpha|beta|rc)[.N]][+build]`, or null
1739
+ * when the string is not that shape. Channels other than alpha/beta/rc
1740
+ * (nightly, dev, …) do not parse — the gate fails open on them.
1741
+ */
1742
+ function parseVersion(version) {
1743
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)(?:\.(\d+))?)?(?:\+[0-9a-z.-]+)?$/i.exec(version.trim());
1744
+ if (match === null) return null;
1745
+ const channel = match[4]?.toLowerCase();
1746
+ return {
1747
+ major: Number(match[1]),
1748
+ minor: Number(match[2]),
1749
+ patch: Number(match[3]),
1750
+ rank: channel === void 0 ? RELEASE_RANK : channelRank(channel),
1751
+ serial: match[5] ? Number(match[5]) : 0
1752
+ };
1753
+ }
1754
+ /**
1755
+ * Total order over parsed versions: X.Y.Z numerically first, then the
1756
+ * channel rank, then the prerelease serial.
1757
+ */
1758
+ function compareParsed(a, b) {
1759
+ if (a.major !== b.major) return a.major - b.major;
1760
+ if (a.minor !== b.minor) return a.minor - b.minor;
1761
+ if (a.patch !== b.patch) return a.patch - b.patch;
1762
+ if (a.rank !== b.rank) return a.rank - b.rank;
1763
+ return a.serial - b.serial;
1764
+ }
1765
+ /**
1766
+ * Whether `version` satisfies the supported baseline. FAIL OPEN by design: a
1767
+ * version that cannot be parsed (a dev/nightly harness build) must not blank
1768
+ * a working deployment, so it passes — the gate trips only on a proven
1769
+ * below-baseline release.
1770
+ */
1771
+ function meetsBaseline(version, baseline = BASELINE_DSH_VERSION) {
1772
+ const v = parseVersion(version);
1773
+ const b = parseVersion(baseline);
1774
+ if (v === null || b === null) return true;
1775
+ return compareParsed(v, b) >= 0;
1776
+ }
1777
+ //#endregion
1778
+ //#region src/host/headers.ts
1779
+ /**
1780
+ * The `contextHeaders` session projection unit — the request-header EPOCH
1781
+ * METADATA behind the timeline's envelope figures.
1782
+ *
1783
+ * The hot `contextTimeline` unit carries only token prices of the system
1784
+ * prompt and tool schemas; this companion unit keeps the per-epoch METADATA
1785
+ * (epoch seq/time boundaries, per-tool token prices and plugin attribution)
1786
+ * so the Context browser can pick the header epoch in force at any step and
1787
+ * size its sections immediately. The epoch CONTENT (full system prompt text,
1788
+ * full tool JSON schemas) deliberately does NOT ride the projection VALUE:
1789
+ * session projections are served whole in every `session.list` row, control
1790
+ * baseline, push frame, and change notification, so carrying content here
1791
+ * multiplied it by sessions × epochs across every channel. The client
1792
+ * fetches one epoch's `request/header` event on demand — a seq-anchored
1793
+ * history read off the epoch's `seq`, the same targeted read the browser
1794
+ * already uses for message content — and caches it per session (history is
1795
+ * immutable).
1796
+ *
1797
+ * Read-compat over the persisted state (the pinned decision behind keeping
1798
+ * `stateVersion` at 1): the harness serves a cold session's projections from
1799
+ * its CACHED checkpoint rows and has no refresh channel for an idle session
1800
+ * — a version bump invalidates every row and orphans the key until the
1801
+ * session goes live again (the #37 regression). The state therefore still
1802
+ * ACCEPTS the v1 content-bearing record shape, current folds append
1803
+ * metadata-only records alongside any seeded legacy ones, and the view
1804
+ * normalizes BOTH to the metadata-only wire shape (pricing the legacy system
1805
+ * text at read time). Cached v1 rows keep working, new checkpoint writes
1806
+ * shrink as legacy epochs age out of the capped list, and the wire — the
1807
+ * part every delivery channel carries — is metadata-only from day one.
1808
+ *
1809
+ * Same projection contract as the timeline unit: pure init/apply/view,
1810
+ * `Object.is` reference stability for uninteresting events, plain-JSON
1811
+ * bounded state (epoch list capped — see HEADERS_MAX).
1812
+ */
1813
+ /** Retention cap on header epochs (metadata only; changes are rare; 50 is generous). */
1814
+ const HEADERS_MAX = 50;
1815
+ /**
1816
+ * The persisted-state schema: the SUPERSET of both record generations, so a
1817
+ * cached v1 row (content-bearing) seeds the fold instead of being discarded.
1818
+ */
1819
+ const storedToolSchema = z.object({
1820
+ name: z.string(),
1821
+ tokens: z.number().int().nonnegative(),
1822
+ description: z.string().optional(),
1823
+ plugin: z.string().optional(),
1824
+ schema: z.unknown().optional()
1825
+ }).strict();
1826
+ const storedEpochSchema = z.object({
1827
+ seq: z.number(),
1828
+ time: z.number(),
1829
+ system: z.string().optional(),
1830
+ systemTokens: z.number().int().nonnegative().optional(),
1831
+ tools: z.array(storedToolSchema)
1832
+ }).strict();
1833
+ const contextHeadersStateSchema = z.object({ headers: z.array(storedEpochSchema) }).strict();
1834
+ /** The wire schema: strict metadata — the shape every delivery channel carries. */
1835
+ const headerToolWireSchema = z.object({
1836
+ name: z.string(),
1837
+ tokens: z.number().int().nonnegative(),
1838
+ plugin: z.string().optional()
1839
+ }).strict();
1840
+ /** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
1841
+ const contextHeadersSchema = z.object({ headers: z.array(z.object({
1842
+ seq: z.number(),
1843
+ time: z.number(),
1844
+ systemTokens: z.number().int().nonnegative().optional(),
1845
+ tools: z.array(headerToolWireSchema)
1846
+ }).strict()) }).strict();
1847
+ function recordOf(event) {
1848
+ if (event.type !== "request/header") return null;
1849
+ const rawHeader = event.data.header;
1850
+ if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
1851
+ const header = rawHeader;
1852
+ const tools = Array.isArray(header.tools) ? header.tools : [];
1853
+ const record = {
1854
+ seq: event.seq,
1855
+ time: event.time,
1856
+ tools: tools.map((t) => {
1857
+ const tool = t !== null && typeof t === "object" ? t : {};
1858
+ const entry = {
1859
+ name: typeof tool.name === "string" ? tool.name : "?",
1860
+ tokens: estimateToolSchema(t)
1861
+ };
1862
+ if (typeof tool.plugin === "string" && tool.plugin !== "") entry.plugin = tool.plugin;
1863
+ return entry;
1864
+ })
1865
+ };
1866
+ if (typeof header.system === "string" && header.system.length > 0) record.systemTokens = estimateSystemTokens(header.system);
1867
+ return record;
1868
+ }
1869
+ /**
1870
+ * The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
1871
+ * `useProjection('contextHeaders')` and fetch an epoch's full content on demand via the session history (historyPage.ts).
1872
+ * Contract mirror with a REQUIRED `wire` block (see compat.ts).
1873
+ * @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
1874
+ * epochs folded without attribution still render a tag when the source is known.
1875
+ */
1876
+ function createContextHeadersDefinition(resolve) {
1877
+ const view = (state) => ({ headers: state.headers.map((h) => {
1878
+ const record = {
1879
+ seq: h.seq,
1880
+ time: h.time,
1881
+ tools: h.tools.map((t) => {
1882
+ const entry = {
1883
+ name: t.name,
1884
+ tokens: t.tokens
1885
+ };
1886
+ const plugin = t.plugin ?? (resolve !== void 0 ? resolve(t.name) : void 0);
1887
+ if (plugin !== void 0) entry.plugin = plugin;
1888
+ return entry;
1889
+ })
1890
+ };
1891
+ const systemTokens = h.systemTokens ?? (typeof h.system === "string" && h.system !== "" ? estimateSystemTokens(h.system) : void 0);
1892
+ if (systemTokens !== void 0) record.systemTokens = systemTokens;
1893
+ return record;
1894
+ }) });
1895
+ return {
1896
+ key: "contextHeaders",
1897
+ stateSchema: contextHeadersStateSchema,
1898
+ wire: {
1899
+ viewSchema: contextHeadersSchema,
1900
+ view
1901
+ },
1902
+ init: () => ({ headers: [] }),
1903
+ apply: (state, event) => {
1904
+ const record = recordOf(event);
1905
+ if (record === null) return state;
1906
+ const last = state.headers.at(-1);
1907
+ if (last !== void 0 && last.seq === record.seq) return state;
1908
+ const headers = [...state.headers, record];
1909
+ return { headers: headers.length > HEADERS_MAX ? headers.slice(-50) : headers };
1910
+ },
1911
+ stateVersion: 1
1912
+ };
1913
+ }
1408
1914
  //#endregion
1409
1915
  //#region src/host/timeline.ts
1410
1916
  /**
@@ -1485,6 +1991,34 @@ const contextEventSchema = z.object({
1485
1991
  turn: z.number().optional(),
1486
1992
  step: z.number().optional()
1487
1993
  }).strict();
1994
+ /** The fold-derived file-operation record (shared/types.ts FileOpRecord). */
1995
+ const fileOpSchema = z.object({
1996
+ seq: z.number().int().nonnegative(),
1997
+ path: z.string(),
1998
+ kind: z.enum([
1999
+ "read",
2000
+ "write",
2001
+ "search"
2002
+ ]),
2003
+ tool: z.string(),
2004
+ time: z.number().optional(),
2005
+ err: z.boolean(),
2006
+ added: z.number().int().nonnegative(),
2007
+ removed: z.number().int().nonnegative(),
2008
+ detail: z.string().optional(),
2009
+ hits: z.number().int().positive().optional(),
2010
+ read: z.union([z.object({
2011
+ start: z.number().int().positive(),
2012
+ count: z.number().int().nonnegative()
2013
+ }).strict(), z.object({
2014
+ count: z.number().int().positive(),
2015
+ est: z.literal(true)
2016
+ }).strict()]).optional(),
2017
+ parent: z.number().int().nonnegative().optional(),
2018
+ program: z.string().optional(),
2019
+ pattern: z.literal(true).optional(),
2020
+ gone: z.number().int().nonnegative().optional()
2021
+ }).strict();
1488
2022
  const currentSchema = z.object({
1489
2023
  system: z.number().int().nonnegative(),
1490
2024
  tools: z.number().int().nonnegative(),
@@ -1522,7 +2056,28 @@ const unsupportedSchema = z.object({
1522
2056
  current: z.string(),
1523
2057
  minimum: z.string()
1524
2058
  }).strict();
1525
- /** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
2059
+ /** The stats board's precomputed count figures (the split head see Snapshot.counts). */
2060
+ const countsSchema = z.object({
2061
+ turns: z.number().int().nonnegative(),
2062
+ steps: z.number().int().nonnegative(),
2063
+ injects: z.number().int().nonnegative(),
2064
+ compactions: z.number().int().nonnegative(),
2065
+ prunes: z.number().int().nonnegative()
2066
+ }).strict();
2067
+ /** The newest retained request's billing summary (the split head's headline anchor). */
2068
+ const lastSchema = z.object({
2069
+ seq: z.number(),
2070
+ total: z.number().int().nonnegative(),
2071
+ prompt: z.number().int().nonnegative().optional()
2072
+ }).strict();
2073
+ /**
2074
+ * One wire contract for both generations: the SPLIT head (envelope scalars +
2075
+ * counts/last/detailRev; the heavy collections stay absent — they ride the
2076
+ * on-demand detail channel, host/detail.ts) and the INLINE value
2077
+ * (channel-less hosts and the fallback unit carry the collections in place).
2078
+ * The collections are therefore optional on the schema; the split marker is
2079
+ * `detailRev` (present ⟺ split).
2080
+ */
1526
2081
  const contextTimelineSchema = z.object({
1527
2082
  ok: z.literal(true),
1528
2083
  unsupported: unsupportedSchema.optional(),
@@ -1532,18 +2087,23 @@ const contextTimelineSchema = z.object({
1532
2087
  current: currentSchema,
1533
2088
  images: z.number().int().nonnegative().optional(),
1534
2089
  toolCalls: z.number().int().nonnegative().optional(),
1535
- requests: z.array(requestRecordSchema),
1536
- events: z.array(contextEventSchema),
2090
+ counts: countsSchema.optional(),
2091
+ last: lastSchema.optional(),
2092
+ detailRev: z.number().int().nonnegative().optional(),
2093
+ requests: z.array(requestRecordSchema).optional(),
2094
+ events: z.array(contextEventSchema).optional(),
1537
2095
  cost: z.object({
1538
2096
  flash: costFamilySchema.optional(),
1539
2097
  pro: costFamilySchema.optional()
1540
2098
  }).strict().optional(),
1541
2099
  timing: timingTotalsSchema.optional(),
1542
- nodes: z.array(surfaceNodeSchema),
1543
- droppedNodes: z.number().int().nonnegative(),
1544
- archive: z.array(surfaceNodeSchema),
2100
+ nodes: z.array(surfaceNodeSchema).optional(),
2101
+ droppedNodes: z.number().int().nonnegative().optional(),
2102
+ archive: z.array(surfaceNodeSchema).optional(),
1545
2103
  surfaceFloor: z.number().int().nonnegative().optional(),
1546
- archiveFloor: z.number().int().nonnegative().optional()
2104
+ archiveFloor: z.number().int().nonnegative().optional(),
2105
+ fileOps: z.array(fileOpSchema).optional(),
2106
+ fileOpsFloor: z.number().int().nonnegative().optional()
1547
2107
  }).strict();
1548
2108
  /**
1549
2109
  * The persisted fold-state schema (the registry's `stateSchema`
@@ -1580,10 +2140,15 @@ const timelineStateSchema = z.object({
1580
2140
  }).strict().optional(),
1581
2141
  callNames: z.record(z.string(), z.object({
1582
2142
  name: z.string(),
1583
- start: z.number()
2143
+ start: z.number(),
2144
+ argsRaw: z.string().optional()
1584
2145
  }).strict()),
1585
2146
  pendingShadowedSeqs: z.array(z.number()).optional(),
1586
- pendingShadowEventSeq: z.number().optional()
2147
+ pendingShadowEventSeq: z.number().optional(),
2148
+ detailRev: z.number().int().nonnegative().optional(),
2149
+ fileOps: z.array(fileOpSchema),
2150
+ fileOpsFloor: z.number().int().nonnegative().optional(),
2151
+ pendingCodeOps: z.record(z.string(), z.array(fileOpSchema)).optional()
1587
2152
  });
1588
2153
  /**
1589
2154
  * The context-timeline projection unit, created per plugin instance with its
@@ -1603,10 +2168,24 @@ const timelineStateSchema = z.object({
1603
2168
  * `wire` block the registry treats the unit as host-only and never delivers
1604
2169
  * `contextTimeline` to the browser (the Context tab would stay on its
1605
2170
  * loading screen forever).
2171
+ *
2172
+ * `slim` selects the wire generation PER SERVE (a liveness probe, not a
2173
+ * fixed flag): while the on-demand detail channel is live (host/detail.ts),
2174
+ * the wire value is the SLIM head (buildTimelineHead) — the heavy
2175
+ * collections no longer ride every session.list row, control baseline,
2176
+ * follow snapshot, and push frame. Before the channel arms (the connection
2177
+ * service may activate after this plugin) or on a deployment whose
2178
+ * connection/sessions services never compose, the unit serves the INLINE
2179
+ * value so the tab keeps working end to end. Both generations validate
2180
+ * against the same schema (the collections are optional on it), and both
2181
+ * fold the SAME state — the split is view-only, so no `stateVersion` bump
2182
+ * and no cached-row invalidation comes with it (the `detailRev` state field
2183
+ * is additive-optional: older rows restore without it and read as revision
2184
+ * 0).
1606
2185
  */
1607
- function createContextTimelineDefinition(config) {
2186
+ function createContextTimelineDefinition(config, slim) {
1608
2187
  const bounds = resolveBounds(config);
1609
- const view = (state) => buildTimelineView(state, bounds);
2188
+ const view = (state) => slim() ? buildTimelineHead(state) : buildTimelineView(state, bounds);
1610
2189
  return {
1611
2190
  key: "contextTimeline",
1612
2191
  stateSchema: timelineStateSchema,
@@ -1616,7 +2195,7 @@ function createContextTimelineDefinition(config) {
1616
2195
  },
1617
2196
  init: () => createTimelineState(),
1618
2197
  apply: (state, event) => applyTimeline(state, event, bounds),
1619
- stateVersion: 13
2198
+ stateVersion: 15
1620
2199
  };
1621
2200
  }
1622
2201
  //#endregion
@@ -1727,6 +2306,84 @@ function installSettings(ctx) {
1727
2306
  });
1728
2307
  }
1729
2308
  //#endregion
2309
+ //#region src/host/stepIdentity.ts
2310
+ /**
2311
+ * Step-boundary message identity guard (issue #51 compatibility).
2312
+ *
2313
+ * The harness refuses to LOAD any session whose durable log carries a
2314
+ * `user/message` event whose message lacks a non-empty string `id`
2315
+ * (`assertMessageEventShape`) — while the runtime append path runs no such
2316
+ * check. One unidentified message therefore persists silently and permanently
2317
+ * bricks the session at its next load (`... failed validation: session event
2318
+ * at seq N lacks an identified message`). The observed archive held a wrap-up
2319
+ * notice whose `id` (and `summary`) had been stripped upstream of the append —
2320
+ * a shape no shipped harness producer emits, i.e. a delivery-boundary rebuild
2321
+ * outside the audited paths of both this plugin and the harness.
2322
+ *
2323
+ * This guard hardens the durability boundary from the plugin side. The agent
2324
+ * loop appends every step-boundary `user/message` from the `agent/pre-step`
2325
+ * decision's `messages` — the one seam all claimed inbox input (prompts,
2326
+ * steering, tool-deferred context) flows through before it persists. A
2327
+ * prepended listener sits OUTERMOST in that waterfall, so after `next()`
2328
+ * resolves it sees the final message list: any message that would persist
2329
+ * unidentified gets a fresh id; everything else passes by reference, and the
2330
+ * decision object is copied only when a mint happened.
2331
+ *
2332
+ * Fail-open by contract: a hostile entry (one that throws on property access),
2333
+ * a missing list, or any unexpected shape leaves the decision verbatim — a
2334
+ * guard must never break the turn it protects. Minted ids carry the `dshctx-`
2335
+ * prefix so a backfilled message stays identifiable in the wild.
2336
+ */
2337
+ /** Prefix marking an id this guard minted (forensically distinguishable). */
2338
+ const MINTED_ID_PREFIX = "dshctx-";
2339
+ /**
2340
+ * Whether the message fails the harness's restore-time identity check (a
2341
+ * non-empty string id). Non-object entries are unfixable (an id needs a
2342
+ * container) and stay verbatim.
2343
+ */
2344
+ function lacksId(message) {
2345
+ if (typeof message !== "object" || message === null) return false;
2346
+ const id = message.id;
2347
+ return typeof id !== "string" || id === "";
2348
+ }
2349
+ /**
2350
+ * Mint ids for the messages that would persist unidentified.
2351
+ * @param messages - the decision's message list, in append order.
2352
+ * @returns the rewritten list (untouched entries by reference), or undefined
2353
+ * when every entry already carries an identity.
2354
+ */
2355
+ function identifiedMessages(messages) {
2356
+ let copy;
2357
+ for (const [index, message] of messages.entries()) {
2358
+ if (!lacksId(message)) {
2359
+ copy?.push(message);
2360
+ continue;
2361
+ }
2362
+ copy ??= messages.slice(0, index);
2363
+ copy.push({
2364
+ ...message,
2365
+ id: MINTED_ID_PREFIX + randomUUID()
2366
+ });
2367
+ }
2368
+ return copy;
2369
+ }
2370
+ /** Arm the prepended `agent/pre-step` guard on the plugin's context. */
2371
+ function watchStepIdentity(ctx) {
2372
+ ctx.on("agent/pre-step", async (_input, next) => {
2373
+ const decision = await next();
2374
+ try {
2375
+ if (decision.kind !== "enter" || !Array.isArray(decision.messages)) return decision;
2376
+ const identified = identifiedMessages(decision.messages);
2377
+ return identified === void 0 ? decision : {
2378
+ ...decision,
2379
+ messages: identified
2380
+ };
2381
+ } catch {
2382
+ return decision;
2383
+ }
2384
+ }, { prepend: true });
2385
+ }
2386
+ //#endregion
1730
2387
  //#region src/host/version.ts
1731
2388
  /**
1732
2389
  * Runtime harness-version probe behind the baseline gate (host/index.ts).
@@ -1844,7 +2501,9 @@ function apply(ctx, config) {
1844
2501
  return;
1845
2502
  }
1846
2503
  const attribution = createToolAttribution(ctx);
1847
- ctx.sessionProjections.register(createContextTimelineDefinition(config));
2504
+ watchStepIdentity(ctx);
2505
+ const gate = watchDetailChannel(ctx, resolveBounds(config));
2506
+ ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live));
1848
2507
  ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
1849
2508
  installSettings(ctx);
1850
2509
  }