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