super-dsh 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/client/index.js +741 -256
  2. package/cordis.patch.yml +16 -0
  3. package/package.json +7 -2
  4. package/superd-hub/dist/a2a-delivery.d.ts +189 -0
  5. package/superd-hub/dist/a2a-delivery.js +240 -0
  6. package/superd-hub/dist/a2a-host.d.ts +76 -0
  7. package/superd-hub/dist/a2a-host.js +145 -0
  8. package/superd-hub/dist/a2a-ingest.d.ts +31 -0
  9. package/superd-hub/dist/a2a-ingest.js +636 -0
  10. package/superd-hub/dist/a2a-log.d.ts +17 -0
  11. package/superd-hub/dist/a2a-log.js +42 -0
  12. package/superd-hub/dist/a2a-row.d.ts +65 -0
  13. package/superd-hub/dist/a2a-row.js +162 -0
  14. package/superd-hub/dist/a2a-service.d.ts +357 -0
  15. package/superd-hub/dist/a2a-service.js +980 -0
  16. package/superd-hub/dist/a2a-wire.d.ts +220 -0
  17. package/superd-hub/dist/a2a-wire.js +305 -0
  18. package/superd-hub/dist/agent-ids.d.ts +10 -0
  19. package/superd-hub/dist/agent-ids.js +68 -0
  20. package/superd-hub/dist/agent-roster.d.ts +29 -18
  21. package/superd-hub/dist/agent-roster.js +54 -15
  22. package/superd-hub/dist/agent-status.d.ts +119 -0
  23. package/superd-hub/dist/agent-status.js +386 -0
  24. package/superd-hub/dist/client/mesh-api.d.ts +134 -0
  25. package/superd-hub/dist/client/mesh-api.js +219 -0
  26. package/superd-hub/dist/client/mesh-groups.d.ts +63 -0
  27. package/superd-hub/dist/client/mesh-groups.js +66 -0
  28. package/superd-hub/dist/client/roster.d.ts +48 -0
  29. package/superd-hub/dist/client/roster.js +96 -0
  30. package/superd-hub/dist/exec-pty-channel.d.ts +58 -0
  31. package/superd-hub/dist/exec-pty-channel.js +162 -0
  32. package/superd-hub/dist/exec-pty-framing.d.ts +32 -0
  33. package/superd-hub/dist/exec-pty-framing.js +81 -0
  34. package/superd-hub/dist/exec-pty-rpc.d.ts +21 -0
  35. package/superd-hub/dist/exec-pty-rpc.js +128 -0
  36. package/superd-hub/dist/exec-pty-session.d.ts +76 -0
  37. package/superd-hub/dist/exec-pty-session.js +234 -0
  38. package/superd-hub/dist/exec.d.ts +56 -0
  39. package/superd-hub/dist/exec.js +225 -0
  40. package/superd-hub/dist/gateway.d.ts +39 -2
  41. package/superd-hub/dist/gateway.js +62 -3
  42. package/superd-hub/dist/hub-tool.d.ts +174 -0
  43. package/superd-hub/dist/hub-tool.js +464 -0
  44. package/superd-hub/dist/index.d.ts +15 -1
  45. package/superd-hub/dist/index.js +16 -0
  46. package/superd-hub/dist/machines.d.ts +45 -0
  47. package/superd-hub/dist/machines.js +125 -0
  48. package/superd-hub/dist/mesh-federate.d.ts +138 -0
  49. package/superd-hub/dist/mesh-federate.js +223 -0
  50. package/superd-hub/dist/mesh-gateway.d.ts +67 -0
  51. package/superd-hub/dist/mesh-gateway.js +408 -0
  52. package/superd-hub/dist/mesh-link.d.ts +274 -0
  53. package/superd-hub/dist/mesh-link.js +441 -0
  54. package/superd-hub/dist/mesh-rpc.d.ts +123 -0
  55. package/superd-hub/dist/mesh-rpc.js +250 -0
  56. package/superd-hub/dist/mux-client.d.ts +71 -0
  57. package/superd-hub/dist/mux-client.js +281 -0
  58. package/superd-hub/dist/pack-version.d.ts +2 -0
  59. package/superd-hub/dist/pack-version.js +30 -0
  60. package/superd-hub/dist/roster.d.ts +6 -0
  61. package/superd-hub/dist/rpc.d.ts +18 -2
  62. package/superd-hub/dist/rpc.js +34 -4
  63. package/superd-hub/dist/spawn-world.js +3 -1
  64. package/superd-hub/dist/switch-core.d.ts +62 -25
  65. package/superd-hub/dist/switch-core.js +53 -30
  66. package/superd-hub/dist/system-head.d.ts +22 -12
  67. package/superd-hub/dist/system-head.js +100 -84
  68. package/superd-hub/dist/wire-endpoints.d.ts +264 -0
  69. package/superd-hub/dist/wire-endpoints.js +180 -0
  70. package/superd-hub/lib/client/index.js +741 -256
  71. package/superd-hub/package.json +11 -2
  72. package/agent-agy/dist/agent-preset-projection.d.ts +0 -31
  73. package/agent-agy/dist/agent-preset-projection.js +0 -33
  74. package/agent-claude/dist/agent-preset-projection.js +0 -30
  75. package/agent-codex/dist/agent-preset-projection.js +0 -37
  76. package/agent-codex/dist/app-home.js +0 -65
  77. package/agent-codex/dist/spike.js +0 -46
  78. package/agent-hermes/dist/agent-preset-projection.js +0 -34
  79. package/agent-omp/dist/agent-preset-projection.js +0 -36
  80. package/agent-omp/dist/app-home.js +0 -86
  81. package/agent-pi/dist/agent-preset-projection.js +0 -29
  82. package/superd-hub/dist/client-shim.d.ts +0 -28
  83. package/superd-hub/dist/client-shim.js +0 -251
  84. package/superd-hub/dist/index-pass.d.ts +0 -22
  85. package/superd-hub/dist/index-pass.js +0 -47
  86. package/superd-hub/dist/labels.d.ts +0 -45
  87. package/superd-hub/dist/labels.js +0 -83
@@ -1,11 +1,20 @@
1
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
2
+ import { PACK_VERSION } from './pack-version.js';
1
3
  import { listRuntimeKeys } from './targets.js';
2
4
  import { agentLinks } from './agent-roster.js';
5
+ import { ensureSelf } from './machines.js';
3
6
  /** The shared-channel Fetch route path this RPC face owns. */
4
7
  export const AGENT_RUNTIME_RPC_PATH = '/api/agent-runtime';
5
8
  /** The live registry view: native plus every registered foreign context. */
6
9
  export const registryTargets = {
7
10
  listRuntimes: listRuntimeKeys,
8
11
  };
12
+ /** The version this hub SERVES as `hub.version` — the super-dsh PACK version
13
+ * (D6): the subpackage's own 0.1.0 never moves, so peers comparing against it
14
+ * could never see a mismatch. The pack manifest ships exactly two hops above
15
+ * the compiled output in every layout (see pack-version.ts); 'unknown' when
16
+ * unreadable — an honest non-number, not a fake one. */
17
+ const HUB_VERSION = PACK_VERSION;
9
18
  function json(status, body) {
10
19
  return new Response(JSON.stringify(body), {
11
20
  status,
@@ -14,16 +23,35 @@ function json(status, body) {
14
23
  }
15
24
  /**
16
25
  * The control-plane handler: world inventory only.
26
+ *
27
+ * ADDITIVE contract (mesh P1): the GET body gained `hub: {machineId, label,
28
+ * version}` and every `agents` row gained `id` (a6); `available` and all
29
+ * pre-existing agents fields are unchanged — old clients ignore the new
30
+ * fields, new clients read them.
17
31
  * @param request - the incoming `/api/agent-runtime` request.
18
32
  * @param source - live runtime registry view.
33
+ * @param deps - optional home override; defaults to `resolveDshHome()`.
19
34
  * @returns the JSON response.
20
35
  */
21
- export async function handleAgentRuntime(request, source) {
36
+ export async function handleAgentRuntime(request, source, deps) {
22
37
  const available = source.listRuntimes();
23
38
  if (request.method === 'GET' || request.method === 'HEAD') {
24
39
  // The selector asks this face for the roster: keys AND the mount path each
25
- // key is addressed by. Read-only — the roster carries no selection.
26
- return json(200, { available, agents: agentLinks() });
40
+ // key is addressed by. Read-only — the roster carries no selection. The
41
+ // hub block (this machine's m4 + label + hub version) rides along for the
42
+ // multi-machine UI; the a6 row ids ride on every agents row.
43
+ const home = deps?.home ?? resolveDshHome();
44
+ const self = ensureSelf(home, 'local');
45
+ // Self-description discipline (2026-09-28 user ruling): a peer's discovery
46
+ // probe (`?mesh=1`) receives THIS machine's own rows only — a hub never
47
+ // re-broadcasts other remotes' agents to its peers (hub-to-hub is P2P;
48
+ // the requester fans out one probe per paired machine and merges itself).
49
+ // The plain browser GET keeps the full roster — the local UI's machine
50
+ // grouping consumes exactly that.
51
+ const links = new URL(request.url).searchParams.get('mesh') === '1'
52
+ ? agentLinks(home).filter((l) => l.machine === 'local')
53
+ : agentLinks(home);
54
+ return json(200, { available, agents: links, hub: { machineId: self.id, label: self.label, version: HUB_VERSION } });
27
55
  }
28
56
  // A POST that used to switch a global selector has no meaning any more:
29
57
  // the addressed world is the mount path of the request, not a shared value.
@@ -35,7 +63,9 @@ export async function handleAgentRuntime(request, source) {
35
63
  /**
36
64
  * Register the control-plane RPC face on the shared `/api` connection channel
37
65
  * (behind the standard browser-trust + auth fence). No-op when the
38
- * `connection` service is absent (e.g. fake-ctx unit tests).
66
+ * `connection` service is absent (e.g. fake-ctx unit tests). The route answers
67
+ * with the DEFAULT home (`resolveDshHome()` at request time); callers needing
68
+ * a pinned home invoke handleAgentRuntime directly with deps.
39
69
  */
40
70
  export function registerSelectorRpc(ctx, source) {
41
71
  ctx.inject(['connection'], (connCtx) => {
@@ -71,7 +71,9 @@ async function publishWorldInHostResolutionTurn(packages, launcher, opts, worldP
71
71
  const declarer = join(worldProfileDir, 'package.json');
72
72
  const worldScope = join(worldProfileDir, 'node_modules', '@pgmi-builds');
73
73
  const baseNames = new Set(base.entries.map((entry) => entry.name));
74
- for (const name of readdirSync(worldScope, { withFileTypes: true })) {
74
+ // R0 scan guard: a dsh-base world has NO farm — a missing scope is not a
75
+ // publish failure, just nothing to add (existsSync before readdirSync).
76
+ for (const name of existsSync(worldScope) ? readdirSync(worldScope, { withFileTypes: true }) : []) {
75
77
  if (!name.isDirectory() && !name.isSymbolicLink())
76
78
  continue;
77
79
  const pkgName = `@pgmi-builds/${name.name}`;
@@ -457,8 +457,13 @@ export interface HubRosterRow {
457
457
  readonly label: string;
458
458
  /** The row's mount path. */
459
459
  readonly path: string;
460
- /** Machine the runtime comes from (the roster parser guarantees it — `'local'` when an older host shape omitted it). */
461
- readonly machine: string;
460
+ /**
461
+ * Machine the runtime comes from (mesh P2): `'local'` for this machine's
462
+ * rows, the owning machine's m4 for federated rows. Optional — the wire
463
+ * may omit it and the grouping now lives client-side (mesh-groups.ts,
464
+ * fed the machines API's label map).
465
+ */
466
+ readonly machine?: string;
462
467
  }
463
468
  /**
464
469
  * The active agent's display name for the Hub row's right-edge selector: the
@@ -470,25 +475,6 @@ export interface HubRosterRow {
470
475
  * Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
471
476
  */
472
477
  export declare function activeAgentName(activeAgent: string | undefined, roster: readonly HubRosterRow[]): string | undefined;
473
- /** Which machine one roster row groups under: the row's own machine (parser-guaranteed). */
474
- export declare function machineGroup(row: HubRosterRow): string;
475
- /** One machine's group of roster rows, in roster order. */
476
- export interface MachineGroup {
477
- /** The grouping key (the members' shared `machine`). */
478
- readonly machine: string;
479
- /** The rows of this machine, keeping the roster's own order. */
480
- readonly agents: readonly HubRosterRow[];
481
- }
482
- /**
483
- * Group a roster by machine for the HubPanelIcon chevron menu's native
484
- * workspace-list look: one group per distinct `machineGroup` key, groups in
485
- * first-appearance order, members keeping the roster's order inside their
486
- * group. A single-group roster (today's local-only shape) renders exactly
487
- * like the old flat list minus the card border.
488
- *
489
- * Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
490
- */
491
- export declare function machineGroups(rows: readonly HubRosterRow[]): readonly MachineGroup[];
492
478
  /**
493
479
  * What the machine hands the host's gate composition (T-G): the machine-side
494
480
  * ingredients of {@link LandingGateOptions}. Structurally a full
@@ -628,19 +614,70 @@ export interface SwitchController {
628
614
  * switch's pointer-restored view).
629
615
  */
630
616
  export declare function createSwitchController(host: SwitchHost, options?: SwitchOptions): SwitchController;
631
- /** What attemptSwitch does with a roster row: run the machine / report failure / wait for readiness. */
632
- export type PreFlightDecision = 'run' | 'failed' | 'wait';
633
- /** The roster fields the pre-flight reads (the projection carries both since C1 task 3). */
617
+ /** What attemptSwitch does with a roster row: run the machine / report failure / wait for readiness / fail fast on a degraded federated row. */
618
+ export type PreFlightDecision = 'run' | 'failed' | 'wait' | 'machine-down';
619
+ /**
620
+ * The roster fields the pre-flight reads (the projection carries both since
621
+ * C1 task 3; `machine` since the mesh P2 roster dimension).
622
+ */
634
623
  export interface PreFlightRow {
635
624
  ready?: boolean;
636
625
  failed?: boolean;
626
+ /**
627
+ * Owning machine (m4) for FEDERATED rows — `'local'` or absent means this
628
+ * machine's own world (the C1 spawn-wait semantics). The client's
629
+ * `AgentLink` satisfies this structurally; the field decides the I1(c)
630
+ * fail-fast branch.
631
+ */
632
+ machine?: string;
637
633
  }
638
634
  /**
639
635
  * The pure pre-flight decision. `native` always runs; an absent row (roster
640
636
  * fetch failed, or the key is not listed) also runs — the machine's roster
641
- * probe is the authority on unknown targets, exactly as before C1.
637
+ * probe is the authority on unknown targets, exactly as before C1. A
638
+ * not-ready FEDERATED row (`machine` set and not `'local'`) decides
639
+ * `'machine-down'` — an immediate actionable failure, never a wait loop
640
+ * (the hub's probe already knows the machine is unreachable or its auth
641
+ * expired); `failed` keeps precedence everywhere (a peer-reported spawn
642
+ * rejection still reports as failed).
642
643
  */
643
644
  export declare function decidePreFlight(label: string, row: PreFlightRow | undefined): PreFlightDecision;
645
+ /** Bound on the machine-down branch's enrichment fetch (mesh P3 Task 5a:
646
+ * the parked P2 minor — an unbounded `fetchMachines` await — folds here; a
647
+ * hung hub answer must never swallow the toast). */
648
+ export declare const MACHINE_DOWN_ENRICH_MS = 3000;
649
+ /** The machines-face slice the machine-down feedback reads (the client's
650
+ * `fetchMachines()` satisfies this structurally). */
651
+ export interface MachineDownMachinesFace {
652
+ machines: readonly {
653
+ id: string;
654
+ label: string;
655
+ status: string;
656
+ }[];
657
+ }
658
+ /** The branch's injected faces — the browser wiring supplies the real
659
+ * fetch/reprobe/toast; tests fake all of them. */
660
+ export interface MachineDownFaces {
661
+ /** Same-origin GET of the hub machine list (enrichment only). */
662
+ fetchMachines(): Promise<MachineDownMachinesFace>;
663
+ /** The I1(b) light probe: POST `{op: 'reprobe', id}` — fire-and-forget. */
664
+ reprobe(id: string): Promise<void>;
665
+ toast(message: string): void;
666
+ setTimeout(fn: () => void, ms: number): unknown;
667
+ clearTimeout(handle: unknown): void;
668
+ }
669
+ /**
670
+ * The machine-down branch's pure half (I1(c) feedback + I1(b) light probe,
671
+ * mesh P3 Task 5a): enrich the toast through the machines face — BOUNDED at
672
+ * {@link MACHINE_DOWN_ENRICH_MS} — then, after toasting, fire ONE reprobe
673
+ * for the row's machine and forget it: a stale-unreachable machine gets a
674
+ * fresh attempt per failed click, so the NEXT click can succeed without
675
+ * waiting for the hub's 5-minute sweep. Never blocks the UI path (the whole
676
+ * body is detached), never surfaces a reprobe failure (the sweep retries).
677
+ */
678
+ export declare function runMachineDownFeedback(label: string, row: PreFlightRow & {
679
+ label?: string;
680
+ }, faces: MachineDownFaces): void;
644
681
  /** Default bound on one readiness wait (the page never locks behind a stuck world). */
645
682
  export declare const WAIT_BOUND_MS = 120000;
646
683
  /** Default poll interval for one readiness wait. */
@@ -502,34 +502,6 @@ export function activeAgentName(activeAgent, roster) {
502
502
  return undefined;
503
503
  return roster.find((row) => row.key === activeAgent)?.label ?? activeAgent;
504
504
  }
505
- /** Which machine one roster row groups under: the row's own machine (parser-guaranteed). */
506
- export function machineGroup(row) {
507
- return row.machine;
508
- }
509
- /**
510
- * Group a roster by machine for the HubPanelIcon chevron menu's native
511
- * workspace-list look: one group per distinct `machineGroup` key, groups in
512
- * first-appearance order, members keeping the roster's order inside their
513
- * group. A single-group roster (today's local-only shape) renders exactly
514
- * like the old flat list minus the card border.
515
- *
516
- * Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
517
- */
518
- export function machineGroups(rows) {
519
- const groups = [];
520
- const byMachine = new Map();
521
- for (const row of rows) {
522
- const machine = machineGroup(row);
523
- let group = byMachine.get(machine);
524
- if (group === undefined) {
525
- group = { machine, agents: [] };
526
- byMachine.set(machine, group);
527
- groups.push(group);
528
- }
529
- group.agents.push(row);
530
- }
531
- return groups;
532
- }
533
505
  /**
534
506
  * Build the switch machine over an injected {@link SwitchHost}.
535
507
  *
@@ -735,7 +707,12 @@ export function createSwitchController(host, options = {}) {
735
707
  /**
736
708
  * The pure pre-flight decision. `native` always runs; an absent row (roster
737
709
  * fetch failed, or the key is not listed) also runs — the machine's roster
738
- * probe is the authority on unknown targets, exactly as before C1.
710
+ * probe is the authority on unknown targets, exactly as before C1. A
711
+ * not-ready FEDERATED row (`machine` set and not `'local'`) decides
712
+ * `'machine-down'` — an immediate actionable failure, never a wait loop
713
+ * (the hub's probe already knows the machine is unreachable or its auth
714
+ * expired); `failed` keeps precedence everywhere (a peer-reported spawn
715
+ * rejection still reports as failed).
739
716
  */
740
717
  export function decidePreFlight(label, row) {
741
718
  if (label === NATIVE_AGENT)
@@ -744,10 +721,56 @@ export function decidePreFlight(label, row) {
744
721
  return 'run';
745
722
  if (row.failed === true)
746
723
  return 'failed';
747
- if (row.ready !== true)
724
+ if (row.ready !== true) {
725
+ // I1(c): a federated not-ready row is DEGRADED, not spawning — fail fast.
726
+ if (row.machine !== undefined && row.machine !== 'local')
727
+ return 'machine-down';
748
728
  return 'wait';
729
+ }
749
730
  return 'run';
750
731
  }
732
+ // ---- I1(b): the machine-down feedback (preflight light probe) ----------------
733
+ /** Bound on the machine-down branch's enrichment fetch (mesh P3 Task 5a:
734
+ * the parked P2 minor — an unbounded `fetchMachines` await — folds here; a
735
+ * hung hub answer must never swallow the toast). */
736
+ export const MACHINE_DOWN_ENRICH_MS = 3_000;
737
+ /**
738
+ * The machine-down branch's pure half (I1(c) feedback + I1(b) light probe,
739
+ * mesh P3 Task 5a): enrich the toast through the machines face — BOUNDED at
740
+ * {@link MACHINE_DOWN_ENRICH_MS} — then, after toasting, fire ONE reprobe
741
+ * for the row's machine and forget it: a stale-unreachable machine gets a
742
+ * fresh attempt per failed click, so the NEXT click can succeed without
743
+ * waiting for the hub's 5-minute sweep. Never blocks the UI path (the whole
744
+ * body is detached), never surfaces a reprobe failure (the sweep retries).
745
+ */
746
+ export function runMachineDownFeedback(label, row, faces) {
747
+ void (async () => {
748
+ let machineName = row.machine;
749
+ let status;
750
+ const enriched = await new Promise((resolve) => {
751
+ const bound = faces.setTimeout(() => resolve(undefined), MACHINE_DOWN_ENRICH_MS);
752
+ faces.fetchMachines().then((list) => { faces.clearTimeout(bound); resolve(list); }, () => { faces.clearTimeout(bound); resolve(undefined); });
753
+ });
754
+ if (enriched !== undefined) {
755
+ const found = enriched.machines.find((entry) => entry.id === row.machine);
756
+ if (found !== undefined) {
757
+ machineName = found.label;
758
+ if (found.status === 'unreachable' || found.status === 'auth-expired')
759
+ status = found.status;
760
+ }
761
+ }
762
+ const who = row.label ?? label;
763
+ const machine = machineName ?? 'its machine';
764
+ faces.toast(status === undefined
765
+ ? `Super D: ${who} is unavailable — machine ${machine} is not ready. Reprobe or re-add it in the Hub machines panel.`
766
+ : `Super D: ${who} is unavailable — machine ${machine} reports ${status}. Reprobe or re-add it in the Hub machines panel.`);
767
+ // I1(b): exactly ONE fresh attempt per failed click — keyed by the row's
768
+ // machine id (the machine-down decision guarantees it is set; a malformed
769
+ // row simply skips the light probe).
770
+ if (row.machine !== undefined)
771
+ faces.reprobe(row.machine).catch(() => { });
772
+ })();
773
+ }
751
774
  /** Default bound on one readiness wait (the page never locks behind a stuck world). */
752
775
  export const WAIT_BOUND_MS = 120_000;
753
776
  /** Default poll interval for one readiness wait. */
@@ -10,16 +10,10 @@
10
10
  * (before any step) — so every bridge-written log violated one rule or the
11
11
  * other and became unresumable across restarts.
12
12
  *
13
- * The native loop never hits this because it opens the step before writing
14
- * anything: `turn/start → step/start → system/message → user/message …`,
15
- * and later prompt changes REPLACE the protected head (the operation
16
- * compaction also uses). This module gives bridge adapters that posture with
17
- * a one-line wrap:
18
- *
19
- * this.session = withSystemHead(session, () => createSystemMessage(""))
20
- *
21
- * The wrap is a transparent Proxy that tracks the open turn/step (exactly as
22
- * the v4 reader does) and intervenes minimally:
13
+ * The wrap installs the co-writer as OWN PROPERTIES on the session object
14
+ * itself (an `append` own-property shadow over the prototype method, plus
15
+ * `stampSystemPrompt`) and tracks the open turn/step (exactly as the v4
16
+ * reader does), intervening minimally:
23
17
  *
24
18
  * - the FIRST `user/message` append on a still-headless, still-empty
25
19
  * surface first opens the turn's next step (when a turn is open but no
@@ -35,6 +29,18 @@
35
29
  * queued and flushed inside the next step the adapter opens;
36
30
  * - everything else delegates unchanged.
37
31
  *
32
+ * IDENTITY IS THE CONTRACT (2026-09-28, D1): the wrap must return the session
33
+ * object ITSELF, never a wrapper. Upstream identity-checks live sessions by
34
+ * object equality — `session-title`'s `rename()` refuses unless
35
+ * `ctx.sessions.get(session.id) === session` (session-title/src/index.ts),
36
+ * and adapters register the RAW prepared session with `sessions.enter()` /
37
+ * `announce()` (e.g. agent-pi/src/index.ts). An earlier revision returned a
38
+ * transparent `Proxy`; every world-context rename then failed with
39
+ * `session "<id>" is not live in this store` because the Agent handle's
40
+ * `session` (the Proxy) was never the store-registered object. Returning the
41
+ * same object keeps `agent.session === ctx.sessions.get(id)` true, so the
42
+ * whole upstream session plane (rename included) sees one identity.
43
+ *
38
44
  * A surface that already starts with a user message and has no head (legacy
39
45
  * logs) stays headless — valid — and is never stamped: appending a head
40
46
  * there is exactly the corruption this module exists to prevent.
@@ -54,7 +60,7 @@ export interface HeadAwareSessionFace {
54
60
  }
55
61
  /** Builds the placeholder head message (adapters pass their createSystemMessage). */
56
62
  export type HeadPlaceholder = () => unknown;
57
- /** One co-written session: transparent delegation + head bookkeeping. */
63
+ /** One co-written session: the session itself, carrying head bookkeeping (object identity preserved — see the module note). */
58
64
  export type WithSystemHead<S> = S & {
59
65
  /** Append-or-replace the protected head with the runtime's system prompt. */
60
66
  stampSystemPrompt(turn: number, step: number, message: unknown): void;
@@ -64,6 +70,10 @@ export type WithSystemHead<S> = S & {
64
70
  * @param session - the adapter's real Session.
65
71
  * @param placeholder - builds the placeholder head message when the first
66
72
  * user message arrives on an empty surface.
67
- * @returns the session (transparently delegated) with `stampSystemPrompt`.
73
+ * @returns the SAME session object with the co-writer installed as own
74
+ * properties (`append` shadow + `stampSystemPrompt`). Identity is the
75
+ * contract (D1): upstream liveness checks compare
76
+ * `ctx.sessions.get(id) === agent.session`, so the returned object must be
77
+ * the one registered with `sessions.enter()` — never a wrapper.
68
78
  */
69
79
  export declare function withSystemHead<S extends HeadAwareSessionFace>(session: S, placeholder: HeadPlaceholder): WithSystemHead<S>;
@@ -10,16 +10,10 @@
10
10
  * (before any step) — so every bridge-written log violated one rule or the
11
11
  * other and became unresumable across restarts.
12
12
  *
13
- * The native loop never hits this because it opens the step before writing
14
- * anything: `turn/start → step/start → system/message → user/message …`,
15
- * and later prompt changes REPLACE the protected head (the operation
16
- * compaction also uses). This module gives bridge adapters that posture with
17
- * a one-line wrap:
18
- *
19
- * this.session = withSystemHead(session, () => createSystemMessage(""))
20
- *
21
- * The wrap is a transparent Proxy that tracks the open turn/step (exactly as
22
- * the v4 reader does) and intervenes minimally:
13
+ * The wrap installs the co-writer as OWN PROPERTIES on the session object
14
+ * itself (an `append` own-property shadow over the prototype method, plus
15
+ * `stampSystemPrompt`) and tracks the open turn/step (exactly as the v4
16
+ * reader does), intervening minimally:
23
17
  *
24
18
  * - the FIRST `user/message` append on a still-headless, still-empty
25
19
  * surface first opens the turn's next step (when a turn is open but no
@@ -35,6 +29,18 @@
35
29
  * queued and flushed inside the next step the adapter opens;
36
30
  * - everything else delegates unchanged.
37
31
  *
32
+ * IDENTITY IS THE CONTRACT (2026-09-28, D1): the wrap must return the session
33
+ * object ITSELF, never a wrapper. Upstream identity-checks live sessions by
34
+ * object equality — `session-title`'s `rename()` refuses unless
35
+ * `ctx.sessions.get(session.id) === session` (session-title/src/index.ts),
36
+ * and adapters register the RAW prepared session with `sessions.enter()` /
37
+ * `announce()` (e.g. agent-pi/src/index.ts). An earlier revision returned a
38
+ * transparent `Proxy`; every world-context rename then failed with
39
+ * `session "<id>" is not live in this store` because the Agent handle's
40
+ * `session` (the Proxy) was never the store-registered object. Returning the
41
+ * same object keeps `agent.session === ctx.sessions.get(id)` true, so the
42
+ * whole upstream session plane (rename included) sees one identity.
43
+ *
38
44
  * A surface that already starts with a user message and has no head (legacy
39
45
  * logs) stays headless — valid — and is never stamped: appending a head
40
46
  * there is exactly the corruption this module exists to prevent.
@@ -59,14 +65,22 @@ function surfaceIsEmpty(events) {
59
65
  * @param session - the adapter's real Session.
60
66
  * @param placeholder - builds the placeholder head message when the first
61
67
  * user message arrives on an empty surface.
62
- * @returns the session (transparently delegated) with `stampSystemPrompt`.
68
+ * @returns the SAME session object with the co-writer installed as own
69
+ * properties (`append` shadow + `stampSystemPrompt`). Identity is the
70
+ * contract (D1): upstream liveness checks compare
71
+ * `ctx.sessions.get(id) === agent.session`, so the returned object must be
72
+ * the one registered with `sessions.enter()` — never a wrapper.
63
73
  */
64
74
  export function withSystemHead(session, placeholder) {
75
+ // Idempotent: a session already carrying the co-writer is returned as-is.
76
+ if (Object.prototype.hasOwnProperty.call(session, 'stampSystemPrompt'))
77
+ return session;
65
78
  // The one narrow this module performs: the loose face above is what makes
66
79
  // the generic real Session assignable; every call below issues exactly the
67
80
  // SystemHeadAppend shape.
68
81
  const appendHead = session.append.bind(session);
69
82
  const rawAppend = session.append.bind(session);
83
+ const rawAppendFull = session.append.bind(session);
70
84
  // Reader-mirrored turn/step state.
71
85
  let turn = null;
72
86
  let step = null;
@@ -115,80 +129,82 @@ export function withSystemHead(session, placeholder) {
115
129
  }
116
130
  headSeq = appendHead('system/message', { turn: turn ?? turnArg, step: step ?? stepArg, message }, { surfaceOp: { op: 'replace', startSeq: headSeq, endSeq: headSeq }, sourceEventSeqs: [headSeq] }).seq;
117
131
  };
118
- return new Proxy(session, {
119
- get(target, property) {
120
- if (property === 'stampSystemPrompt') {
121
- return (turnArg, stepArg, message) => { stamp(turnArg, stepArg, message); };
132
+ const interceptAppend = (type, data, opts) => {
133
+ if (type === 'turn/start') {
134
+ turn = data.turn;
135
+ nextStep = 1;
136
+ wrapOpenedStep = false;
137
+ wrapClosedStep = undefined;
138
+ }
139
+ else if (type === 'turn/end') {
140
+ // A step THIS wrap opened must not survive the turn boundary.
141
+ if (step !== null && wrapOpenedStep) {
142
+ rawAppend('step/end', { turn, step });
143
+ wrapClosedStep = step;
144
+ step = null;
145
+ nextStep += 1;
146
+ }
147
+ turn = null;
148
+ }
149
+ else if (type === 'step/start') {
150
+ const dataStep = data;
151
+ if (step !== null && wrapOpenedStep && dataStep.turn === turn && dataStep.step === step) {
152
+ // The adapter opened the step the wrap already wrote: drop the
153
+ // duplicate (the adapter's own counters stay consistent).
154
+ return { type, seq: session.snapshotEvents().length, time: Date.now(), data };
155
+ }
156
+ if (step === null && dataStep.step !== undefined)
157
+ step = dataStep.step;
158
+ wrapOpenedStep = false;
159
+ const applied = rawAppendFull(type, data);
160
+ if (queuedStamp !== undefined && step !== null) {
161
+ const pending = queuedStamp;
162
+ queuedStamp = undefined;
163
+ stamp(pending.turn, pending.step, pending.message);
122
164
  }
123
- if (property === 'append') {
124
- return (type, data, opts) => {
125
- if (type === 'turn/start') {
126
- turn = data.turn;
127
- nextStep = 1;
128
- wrapOpenedStep = false;
129
- wrapClosedStep = undefined;
130
- }
131
- else if (type === 'turn/end') {
132
- // A step THIS wrap opened must not survive the turn boundary.
133
- if (step !== null && wrapOpenedStep) {
134
- rawAppend('step/end', { turn, step });
135
- wrapClosedStep = step;
136
- step = null;
137
- nextStep += 1;
138
- }
139
- turn = null;
140
- }
141
- else if (type === 'step/start') {
142
- const dataStep = data;
143
- if (step !== null && wrapOpenedStep && dataStep.turn === turn && dataStep.step === step) {
144
- // The adapter opened the step the wrap already wrote: drop the
145
- // duplicate (the adapter's own counters stay consistent).
146
- return { type, seq: session.snapshotEvents().length, time: Date.now(), data };
147
- }
148
- if (step === null && dataStep.step !== undefined)
149
- step = dataStep.step;
150
- wrapOpenedStep = false;
151
- const applied = rawAppend(type, data);
152
- if (queuedStamp !== undefined && step !== null) {
153
- const pending = queuedStamp;
154
- queuedStamp = undefined;
155
- stamp(pending.turn, pending.step, pending.message);
156
- }
157
- return applied;
158
- }
159
- else if (type === 'step/end') {
160
- const dataStep = data;
161
- if (step === null && wrapClosedStep !== undefined && dataStep.step === wrapClosedStep) {
162
- wrapClosedStep = undefined;
163
- return { type, seq: session.snapshotEvents().length, time: Date.now(), data }; // the wrap already closed it
164
- }
165
- step = null;
166
- if (dataStep.step !== undefined)
167
- nextStep = dataStep.step + 1;
168
- }
169
- else if (type === 'user/message') {
170
- ensureHead();
171
- }
172
- else if (type === 'system/message') {
173
- // A direct adapter append (notices, bootstraps): give it the step
174
- // context the reader demands — open the turn's next step when
175
- // none is open and NORMALIZE the coordinates to that context
176
- // (requireStep compares them for equality; a mismatched append
177
- // is doomed otherwise). Surface ordering beyond the head rules
178
- // stays the adapter's own responsibility.
179
- if (step === null && turn !== null)
180
- openWrapStep();
181
- if (step !== null && turn !== null) {
182
- const direct = data;
183
- if (direct.turn !== turn || direct.step !== step)
184
- data = { ...data, turn, step };
185
- }
186
- }
187
- return Reflect.apply(target.append, target, [type, data, opts]);
188
- };
165
+ return applied;
166
+ }
167
+ else if (type === 'step/end') {
168
+ const dataStep = data;
169
+ if (step === null && wrapClosedStep !== undefined && dataStep.step === wrapClosedStep) {
170
+ wrapClosedStep = undefined;
171
+ return { type, seq: session.snapshotEvents().length, time: Date.now(), data }; // the wrap already closed it
172
+ }
173
+ step = null;
174
+ if (dataStep.step !== undefined)
175
+ nextStep = dataStep.step + 1;
176
+ }
177
+ else if (type === 'user/message') {
178
+ ensureHead();
179
+ }
180
+ else if (type === 'system/message') {
181
+ // A direct adapter append (notices, bootstraps): give it the step
182
+ // context the reader demands — open the turn's next step when
183
+ // none is open and NORMALIZE the coordinates to that context
184
+ // (requireStep compares them for equality; a mismatched append
185
+ // is doomed otherwise). Surface ordering beyond the head rules
186
+ // stays the adapter's own responsibility.
187
+ if (step === null && turn !== null)
188
+ openWrapStep();
189
+ if (step !== null && turn !== null) {
190
+ const direct = data;
191
+ if (direct.turn !== turn || direct.step !== step)
192
+ data = { ...data, turn, step };
189
193
  }
190
- const value = Reflect.get(target, property, target);
191
- return typeof value === 'function' ? value.bind(target) : value;
192
- },
194
+ }
195
+ return rawAppendFull(type, data, opts);
196
+ };
197
+ // Install the co-writer as OWN PROPERTIES on the session itself. The
198
+ // `append` shadow covers every holder of the object (the adapter's Agent
199
+ // AND upstream services that resolved the session from the store); both
200
+ // properties are non-enumerable so scans/serialization of the session see
201
+ // nothing, and writable/configurable so it stays an ordinary instance.
202
+ Object.defineProperty(session, 'append', {
203
+ value: interceptAppend, writable: true, configurable: true, enumerable: false,
204
+ });
205
+ Object.defineProperty(session, 'stampSystemPrompt', {
206
+ value: (turnArg, stepArg, message) => { stamp(turnArg, stepArg, message); },
207
+ writable: true, configurable: true, enumerable: false,
193
208
  });
209
+ return session;
194
210
  }