@twinsearth/w2m-dsh-plugin 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.9.6] — 2026-10-14
8
+
9
+ **Individual memory: the fleet can now answer "what did we already try on this machine, and what happened?" — a question nothing in it could answer before.**
10
+
11
+ ### The gap this closes, measured rather than asserted
12
+
13
+ The relay is sent `command_hash`, `stdout_sha256` and `stdout_bytes`, and never the text. That choice is
14
+ defensible and stays: the ledger is a tamper-evident consistency record, and a relay compromise discloses
15
+ no command text. The price was that no part of the fleet retained what a command actually did, and the
16
+ price was paid over and over. In one working session the same facts were rediscovered from scratch across
17
+ a dozen dispatches — that a machine had no package manager (3 times), which node binary actually ran (2),
18
+ that its git could not reach github.com while node's HTTPS could (2), that a task is expired at about
19
+ fifty seconds (3), and which commit its checkout was on (2).
20
+
21
+ ### What was added
22
+
23
+ **`src/agent/memory.mjs` — one append-only `memory.jsonl` per machine, in its own state directory.**
24
+
25
+ Written by the agent as each task finishes, at the one point where the output still exists at all and on
26
+ the one machine that owns it. Each line carries the command, `cwd_rel`, exit code, status, duration,
27
+ transport, and the tails of stdout and stderr — plus the envelope's hashes, so a human-readable record can
28
+ be cross-checked against the relay's tamper-evident one without trusting either.
29
+
30
+ Three properties are deliberate and each has a test:
31
+
32
+ - **Bounded by construction**, with a byte cap and one rotating generation. This is not hypothetical
33
+ caution: the Linux host's root filesystem reached 100% today from 14.7 GB/day of uncapped logging, and
34
+ the recovery cost real time. A memory that can fill a disk is a worse outcome than no memory. Rotation
35
+ keeps the previous generation rather than discarding it, because the question this file answers is
36
+ usually about the recent past and the rotation boundary sits exactly there.
37
+ - **A malformed line is skipped, not fatal.** A partially written final line is what a machine losing
38
+ power mid-append leaves behind, and one damaged line must not make the rest unreadable — the relay's own
39
+ ledger behaves this way for the same reason.
40
+ - **`conclusion` is written as `null` by the machine and only ever filled in by a reader.** The design
41
+ separates fact from interpretation, and a machine that invented a conclusion would be asserting
42
+ something it cannot know. A test pins this, because the failure would be silent.
43
+
44
+ **`w2m_memory` — the read side.** Returns this machine's memory, newest first, with a status tally, and
45
+ reaches the rotated generation on request.
46
+
47
+ It does **not** read another machine's memory, and says so instead of returning an empty list. The reason
48
+ is structural rather than an omission: the relay carries no such file, and an agent executes a dispatched
49
+ command inside a sandbox whose writes are scoped. A query for another machine is answered with
50
+ `MEMORY_REMOTE_NOT_READABLE` and the two ways to actually read it. Returning `records: []` would have read
51
+ as *"that machine has done nothing"* — the opposite conclusion from *"this build cannot see"*.
52
+
53
+ The write is wrapped so it can never fail a task. The task is the work; this is a note about it.
54
+
55
+ ### Verified
56
+
57
+ - `test/memory.test.mjs` — **18 tests, 0 failures**: record shape, tail and argv clamping, append-and-read,
58
+ newest-first ordering, the limit, a malformed line skipped, an absent file read as empty, rotation with
59
+ both generations readable, and that neither `appendMemory` nor `readMemorySync` throws on a bad directory.
60
+ - A rotation test that trips the cap with *real* records rather than filler, because the first version
61
+ wrote eight megabytes of `'x'` — it rotated, but it could not answer the question being asked, since the
62
+ previous generation then held one unparseable line.
63
+ - `eslint .` — 0 errors.
64
+ - **The full pre-flight is green: 11 pass, 0 fail, 1 unverifiable** (1721 tests in 54 files; 1707 pass,
65
+ 0 fail, 14 platform skips). The single `UNVERIFIABLE` is an accepted host observation, not a gate.
66
+ Reaching that required installing `git` on the development host, which had none; the 39 failures before
67
+ it were all `spawn git ENOENT`, and the two rounds of diagnosis after it were a sandbox rule (writing
68
+ files in `%TEMP%` is allowed, **creating a directory** there is refused — and `git init` creates one) and
69
+ two mojibake sequences this release then repaired. Recorded because "39 failures" read as a defect count
70
+ and was, at every stage, an environment count.
71
+ - The nine-tool contract is updated everywhere it is pinned: `scripts/verify-installed.mjs`,
72
+ `test/loader-entry.test.mjs`, `test/tools.test.mjs`, `test/tool-cards.test.mjs`, `test/claims.test.mjs`,
73
+ `test/p2p-plugin.test.mjs`, `test/update-wiring.test.mjs` and the pre-flight gate inventory. Those
74
+ assertions are what make "the tool set" a contract rather than a habit, so leaving one at eight would
75
+ have failed a release that is otherwise correct.
76
+
7
77
  ## [0.9.5] — 2026-10-14
8
78
 
9
79
  **Same code as v0.9.4; published because v0.9.4's npm and GitHub bytes cannot be made to agree.**
package/README.md CHANGED
@@ -39,7 +39,7 @@ So the cross-machine capability has to be built. This is that build.
39
39
  ▼
40
40
  ┌──────────────────────────────────┐ ┌──────────────────────────┐
41
41
  │ Windows · DSH session │ │ macOS · DSH session │
42
- │ w2m plugin (8 tools) │ │ w2m plugin (8 tools) │
42
+ │ w2m plugin (9 tools) │ │ w2m plugin (9 tools) │
43
43
  │ │ │ │ │ │
44
44
  │ Localside agent │ │ Localside agent │
45
45
  │ · three anchors │ │ · three anchors │
@@ -293,15 +293,15 @@ Then give it the relay URL in that profile's `cordis.patch.yml` — or leave
293
293
  > got "@twinsearth/w2m-dsh-plugin/tools"), skipping
294
294
  > ```
295
295
  >
296
- > That is a silent failure by construction — the install looks right, eight tools appear, and
296
+ > That is a silent failure by construction — the install looks right, nine tools appear, and
297
297
  > `w2m_status` reports `paired: false` and a null `rabbitUrl` because none of your values were ever
298
298
  > applied. It is not hypothetical: this row's expected name moved from the `/tools` subpath to the
299
299
  > package root in v0.5.4 (see `cordis.patch.yml`'s header for why), and every override that still
300
300
  > asserted the old subpath stopped being applied on upgrade. `id` already identifies the row, so the
301
301
  > safest `name:` is no `name:` at all.
302
302
 
303
- Restart DSH. You should see eight tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
304
- `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`.
303
+ Restart DSH. You should see nine tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
304
+ `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`.
305
305
 
306
306
  > `dsh` may not be on your `PATH`. On a packaged install it lives under
307
307
  > `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
@@ -584,7 +584,7 @@ a product.
584
584
  |---|---|---|
585
585
  | relay | 142 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all **nine** aggregation states plus `pending`/`refused`, report generation, operator token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
586
586
  | agent | 129 | allow-list allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts, and the bundled-runtime resolver |
587
- | tools | 89 | eight tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
587
+ | tools | 89 | nine tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
588
588
  | broadcast | 19 | exactly one executor, the rest `observing`; observers excluded from every aggregation step so a machine that did not run is never counted as agreeing |
589
589
  | claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
590
590
  | fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
@@ -712,7 +712,7 @@ the plugin:
712
712
 
713
713
  ```bash
714
714
  node scripts/check-entrypoints.mjs # imports lib/tools.js, the exact specifier cordis.patch.yml mounts
715
- node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 8 tools
715
+ node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 9 tools
716
716
  ```
717
717
 
718
718
  **Not verified:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -111,13 +111,13 @@
111
111
  }
112
112
  },
113
113
  "scripts": {
114
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
114
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
115
115
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
116
116
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
117
- "test:agent": "node --test --test-force-exit test/agent.test.mjs",
117
+ "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
118
118
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
119
119
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
120
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
120
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
121
121
  "pack": "node scripts/pack.mjs",
122
122
  "verify:join": "node scripts/verify-join.mjs",
123
123
  "lint": "eslint .",
@@ -125,7 +125,8 @@
125
125
  "format": "prettier --write .",
126
126
  "format:check": "prettier --check .",
127
127
  "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
128
- "test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs"
128
+ "test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs",
129
+ "test:memory": "node --test --test-force-exit test/memory.test.mjs"
129
130
  },
130
131
  "keywords": [
131
132
  "dsh-bundle",
@@ -48,6 +48,7 @@ import {
48
48
  resolveProjectCwd,
49
49
  } from './git.mjs';
50
50
  import { createSpool } from './spool.mjs';
51
+ import { appendMemory, memoryRecord } from './memory.mjs';
51
52
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
52
53
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
53
54
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
@@ -2638,6 +2639,32 @@ export function createAgent(options) {
2638
2639
  const directResult = (context.p2pDelivery ?? p2pDeliveryMark()).path === 'p2p';
2639
2640
  envelope.transport = transport.offer_path === 'p2p' || directResult ? 'p2p' : transport.offer_path;
2640
2641
  envelope.p2p = envelopeP2P({ ...context, p2pTransport: transport });
2642
+
2643
+ /**
2644
+ * v0.9.6 individual memory: write what this machine just did, where the text still exists.
2645
+ *
2646
+ * This is the last point at which the output is available to anything that will keep it. The relay
2647
+ * is sent `stdout_sha256` and `stdout_bytes` and never the text -- deliberately, so that a relay
2648
+ * compromise discloses no command text and the ledger stays a consistency record rather than a
2649
+ * transcript. The consequence is that a question like "what did we already try on this machine, and
2650
+ * what happened?" had no answer anywhere in the fleet, and the same facts were rediscovered from
2651
+ * scratch repeatedly at the cost of a dozen dispatches in one session.
2652
+ *
2653
+ * The write happens **after** the envelope is complete, so the record it stores is the same one the
2654
+ * relay will judge -- not a second, independently assembled opinion about what happened.
2655
+ *
2656
+ * `appendMemory` never throws and its result is deliberately not returned or checked: the task is
2657
+ * the work and this is a note about it, so a full disk or an unwritable directory must not turn a
2658
+ * successful command into a failed task. A memory that can fail work is worse than no memory, and
2659
+ * the failure mode is silent here on purpose -- the envelope's own fields already say what happened.
2660
+ */
2661
+ appendMemory(stateDir, memoryRecord({
2662
+ envelope,
2663
+ commandArgv,
2664
+ cwdRel,
2665
+ ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
2666
+ }));
2667
+
2641
2668
  return buildEnvelope(envelope);
2642
2669
  }
2643
2670
 
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Individual memory: what this machine did, in its own state directory.
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * The relay's ledger is the only durable fleet record, and it deliberately keeps **hashes, not text** --
7
+ * `command_hash`, `stdout_sha256`, `stdout_bytes`. That choice is defensible: the ledger is a
8
+ * tamper-evident consistency record, and a relay compromise discloses no command text. The cost is that
9
+ * nothing in the fleet can answer *"what did we already try on this machine, and what happened?"*
10
+ *
11
+ * The cost was paid repeatedly. In a single working session the same facts were rediscovered from
12
+ * scratch at the price of a dozen dispatches -- that this box has no package manager, which node binary
13
+ * actually runs, that its git cannot reach github.com while node's HTTPS can, that a task is expired at
14
+ * roughly fifty seconds. None of it was retained anywhere the fleet could read.
15
+ *
16
+ * So the memory lives here, on the machine, for three reasons beyond convenience:
17
+ *
18
+ * 1. the machine already writes `agent-state.json` in this directory, so there is no new place to
19
+ * find and no new permission to grant;
20
+ * 2. it owns the output at the moment it produces it, and this is the last point at which the *text*
21
+ * exists at all -- the relay never receives it, so nothing downstream could store it later;
22
+ * 3. it survives a relay rebuild, which the ledger does not.
23
+ *
24
+ * It is also the only honest home for a machine-specific fact. *"This box has no package manager"* is a
25
+ * property of this box, and a design that put it anywhere else would be guessing.
26
+ *
27
+ * ## Bounded by construction
28
+ *
29
+ * A byte cap with rotation, not an unbounded append. This is not hypothetical caution: the Linux host's
30
+ * root filesystem filled to 100% today from 14.7 GB/day of logs written by a process that had no cap,
31
+ * and the recovery cost real time. A memory that can fill a disk is a worse failure than no memory.
32
+ *
33
+ * Rotation keeps one previous generation rather than discarding it, because the question this file
34
+ * answers is usually about the recent past and a single rotation boundary is exactly where "we just ran
35
+ * that" sits.
36
+ */
37
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+
40
+ /** The memory log's filename, beside `agent-state.json`. */
41
+ export const MEMORY_FILE = 'memory.jsonl';
42
+
43
+ /** The one rotated generation: `<file>.1`. Kept, not deleted -- see the header. */
44
+ export const MEMORY_FILE_PREVIOUS = `${MEMORY_FILE}.1`;
45
+
46
+ /**
47
+ * Byte cap for the live file before it is rotated.
48
+ *
49
+ * 8 MiB holds on the order of 20,000 task records at the shape below -- several years of this fleet's
50
+ * observed rate -- while staying small enough that reading the whole file to answer a question is
51
+ * something a tool can do without a plan.
52
+ */
53
+ export const MEMORY_MAX_BYTES = 8 * 1024 * 1024;
54
+
55
+ /**
56
+ * How much of a stream's tail is kept.
57
+ *
58
+ * The envelope already carries `stdout_tail` / `stderr_tail` for cross-machine comparison, so storing a
59
+ * second copy costs nothing extra and keeps every record the same shape. It is clamped again here
60
+ * rather than trusted, because this file is written on a machine the dispatcher does not control and a
61
+ * cap that can be exceeded by an argument is not a cap.
62
+ */
63
+ export const MEMORY_TAIL_BYTES = 4096;
64
+
65
+ /**
66
+ * A bound on the recorded argv.
67
+ *
68
+ * The relay takes an argv array of arbitrary length, so an offer may legally carry a command longer than
69
+ * any sane memory record. Truncating the *record* is right here: `command_argv` is not compared across
70
+ * machines (only `command_hash` is), so a shortened argv cannot make two machines disagree.
71
+ */
72
+ export const MEMORY_ARGV_LIMIT = 64;
73
+
74
+ /** @param {unknown} value @param {number} limit */
75
+ function clamp(value, limit) {
76
+ if (typeof value !== 'string' || value === '') return null;
77
+ return value.length <= limit ? value : `${value.slice(0, limit)}\n…[truncated at ${limit} characters]`;
78
+ }
79
+
80
+ /**
81
+ * The memory record for one finished task.
82
+ *
83
+ * Shaped to be readable by a person as much as by a tool: a reader asking "what happened here" should be
84
+ * able to `tail` the file and get an answer without a query.
85
+ *
86
+ * `conclusion` is deliberately absent. It is the field §3.1 of the design shows, and it is reserved for
87
+ * `w2m_memory` to fill in when a human or an agent decides what the result *meant* -- a machine that
88
+ * invented one would be asserting an interpretation it cannot have, and the whole point of separating
89
+ * fact from conclusion is that the two must not be confused later.
90
+ *
91
+ * @param {object} input
92
+ * @param {object} input.envelope The built result envelope.
93
+ * @param {string[]} input.commandArgv
94
+ * @param {string|null} input.cwdRel
95
+ * @param {number} input.ms Wall-clock duration of the attempt.
96
+ * @param {number} [input.now]
97
+ * @returns {object}
98
+ */
99
+ export function memoryRecord({
100
+ envelope,
101
+ commandArgv,
102
+ cwdRel,
103
+ ms,
104
+ now = Date.now(),
105
+ }) {
106
+ const argv = Array.isArray(commandArgv) ? commandArgv.map(String) : [];
107
+ const truncatedArgv = argv.length > MEMORY_ARGV_LIMIT;
108
+
109
+ const record = {
110
+ at: new Date(now).toISOString(),
111
+ task_id: envelope.task_id,
112
+ attempt: envelope.attempt,
113
+ machine_id: envelope.machine_id,
114
+ machine_name: envelope.machine_name ?? null,
115
+ mode: envelope.mode ?? null,
116
+ argv: truncatedArgv ? argv.slice(0, MEMORY_ARGV_LIMIT) : argv,
117
+ cwd_rel: cwdRel ?? null,
118
+ // The exit code and status are what a reader checks first, so they sit above the tails.
119
+ exit_code: envelope.exit_code ?? null,
120
+ status: envelope.status ?? null,
121
+ refusal_reason: envelope.refusal_reason ?? null,
122
+ ms: Number.isFinite(ms) ? ms : null,
123
+ // Which way the offer travelled. Useful on its own: "this machine has not been reachable directly
124
+ // since Tuesday" is a fact about the fleet that only these records would show.
125
+ transport: envelope.transport ?? null,
126
+ stdout_tail: clamp(envelope.stdout_tail, MEMORY_TAIL_BYTES),
127
+ stderr_tail: clamp(envelope.stderr_tail, MEMORY_TAIL_BYTES),
128
+ // Hashes are repeated from the envelope on purpose: they are what ties this human-readable record
129
+ // to the relay's tamper-evident one, so the two can be cross-checked without trusting either.
130
+ stdout_sha256: envelope.stdout_sha256 ?? null,
131
+ stdout_bytes: envelope.stdout_bytes ?? null,
132
+ // Filled in later by `w2m_memory`, never by the machine. See above.
133
+ conclusion: null,
134
+ tags: [],
135
+ };
136
+ if (truncatedArgv) record.argv_truncated_from = argv.length;
137
+ return record;
138
+ }
139
+
140
+ /**
141
+ * Append a record, rotating first when the file has reached its cap.
142
+ *
143
+ * Never throws. A memory that can fail a task is worse than a memory that misses one: the task is the
144
+ * work, and this is a note about it. The caller gets `false` and the reason is on the record instead.
145
+ *
146
+ * Writes are synchronous and `O_APPEND`, so two attempts finishing at once interleave as whole lines
147
+ * rather than as corrupt ones -- the failure mode that would make the whole file unreadable.
148
+ *
149
+ * `maxBytes` is a parameter rather than a closed-over constant so that rotation is testable without
150
+ * writing eight megabytes to prove it. A cap that can only be exercised at production size is a cap
151
+ * whose rotation path is only exercised in production, which is the wrong place to find out.
152
+ *
153
+ * @param {string} stateDir
154
+ * @param {object} record
155
+ * @param {{maxBytes?: number}} [options]
156
+ * @returns {{ok: true, path: string, bytes: number, rotated: boolean}|{ok: false, error: string}}
157
+ */
158
+ export function appendMemory(stateDir, record, { maxBytes = MEMORY_MAX_BYTES } = {}) {
159
+ try {
160
+ if (typeof stateDir !== 'string' || stateDir === '') {
161
+ return { ok: false, error: 'MEMORY_NO_STATE_DIR' };
162
+ }
163
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
164
+ const path = join(stateDir, MEMORY_FILE);
165
+
166
+ let rotated = false;
167
+ if (existsSync(path)) {
168
+ const size = statSync(path).size;
169
+ if (size >= maxBytes) {
170
+ // Replace the previous generation rather than skipping rotation: keeping more than one would
171
+ // defeat the cap, and the cap is the point.
172
+ renameSync(path, join(stateDir, MEMORY_FILE_PREVIOUS));
173
+ writeFileSync(path, '', { mode: 0o600 });
174
+ rotated = true;
175
+ }
176
+ }
177
+
178
+ const line = `${JSON.stringify(record)}\n`;
179
+ appendFileSync(path, line, { mode: 0o600 });
180
+ return { ok: true, path, bytes: Buffer.byteLength(line), rotated };
181
+ } catch (error) {
182
+ return { ok: false, error: `MEMORY_APPEND_FAILED: ${error instanceof Error ? error.message : String(error)}` };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read records back, newest first.
188
+ *
189
+ * Ordering is by file position reversed, not by parsing every `at`: the file is append-only, so position
190
+ * *is* time, and sorting would both cost more and mis-order two records written in the same millisecond.
191
+ *
192
+ * A malformed line is skipped rather than fatal. A partially written final line is the expected outcome
193
+ * of a machine losing power mid-append, and one damaged line must not make the rest unreadable -- which
194
+ * is exactly how the relay's own ledger behaves (`ledger_lines_skipped`), and for the same reason.
195
+ *
196
+ * Synchronous on purpose, and named for it: the reader is called from a tool that answers a question
197
+ * about a local file, and every other reader in this codebase (`w2m_status` on `agent-state.json`) is a
198
+ * synchronous read for the same reason -- a memory query that could interleave with an append would
199
+ * report a state the file was never in.
200
+ *
201
+ * @param {string} stateDir
202
+ * @param {{limit?: number, previous?: boolean}} [options]
203
+ * @returns {{records: object[], skipped: number, truncated: boolean, path: string|null, error?: string}}
204
+ */
205
+ export function readMemorySync(stateDir, { limit = 50, previous = false } = {}) {
206
+ const empty = { records: [], skipped: 0, truncated: false, path: null };
207
+ if (typeof stateDir !== 'string' || stateDir === '') return { ...empty, error: 'MEMORY_NO_STATE_DIR' };
208
+ const path = join(stateDir, previous ? MEMORY_FILE_PREVIOUS : MEMORY_FILE);
209
+ if (!existsSync(path)) return { ...empty, path };
210
+
211
+ try {
212
+ const text = statSync(path).size === 0 ? '' : readFileSync(path, 'utf8');
213
+ const lines = text.split('\n');
214
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
215
+
216
+ const records = [];
217
+ let skipped = 0;
218
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
219
+ if (records.length >= limit) break;
220
+ try {
221
+ records.push(JSON.parse(lines[i]));
222
+ } catch {
223
+ skipped += 1;
224
+ }
225
+ }
226
+ return { records, skipped, truncated: records.length >= limit, path };
227
+ } catch (error) {
228
+ return { ...empty, path, error: `MEMORY_READ_FAILED: ${error instanceof Error ? error.message : String(error)}` };
229
+ }
230
+ }
231
+
232
+ /** Retained under the shorter name: the reader has always been synchronous. */
233
+ export const readMemory = readMemorySync;
@@ -35,7 +35,7 @@
35
35
  * Everything in this file is callable by a model, against a fleet whose machines run commands under
36
36
  * their own allow-lists, and it holds an operator token that authorises dispatch fleet-wide. That
37
37
  * makes the *tool surface* a boundary, and this section is the boundary written down as it actually
38
- * is — not as it would sound best. `test/tool-cards.test.mjs` asserts the parts of it that can be
38
+ * is - not as it would sound best. `test/tool-cards.test.mjs` asserts the parts of it that can be
39
39
  * asserted mechanically, and a change that crosses it is meant to turn that suite red.
40
40
  *
41
41
  * **No tool parameter names a credential, an allow-list, a key path, or a state directory.** There is
@@ -49,7 +49,7 @@
49
49
  * and is applied in one direction only: {@link checkAllowedCommand} returns a refusal or `null`, and
50
50
  * there is no path in this file that adds an entry to the list. Each target machine's own agent
51
51
  * re-decides the same gate against its own list, so a dispatcher that lost this check would still not
52
- * widen anything — the direction the shared matcher exists to keep honest (`src/agent/allowed-commands.mjs`).
52
+ * widen anything - the direction the shared matcher exists to keep honest (`src/agent/allowed-commands.mjs`).
53
53
  *
54
54
  * **No tool can change a machine's `p2pMode`.** The mode is resolved once, in `readConfig` at
55
55
  * `apply()` time, from the config object or `W2M_P2P_MODE`, and every dispatch publishes it as a fact
@@ -58,19 +58,19 @@
58
58
  * weaker path, which is the one place a dispatcher could otherwise have quietly downgraded a peer.
59
59
  *
60
60
  * **No tool writes another machine's identity or keys.** `device.json` is read (`readDevice`) and
61
- * `device.key` is loaded (`DeviceKey.load`) — neither is ever written here. What a dispatch publishes
61
+ * `device.key` is loaded (`DeviceKey.load`) - neither is ever written here. What a dispatch publishes
62
62
  * is this machine's own **public** key inside `p2p.static_keys`, so the executor can authenticate a
63
63
  * dial; the private half never leaves `src/agent/crypto.mjs`.
64
64
  *
65
65
  * **No tool result carries a token or a key.** `w2m_status` reports `paired`,
66
66
  * `operatorToken_configured` and `pairingCode_configured` as booleans and never the values. The
67
- * operator token travels only in the `Authorization` header of `POST /v1/task` — never in the request
68
- * body and never in an answer — and `direct_pushes[].static_key` is the literal marker `'published'`,
67
+ * operator token travels only in the `Authorization` header of `POST /v1/task` - never in the request
68
+ * body and never in an answer - and `direct_pushes[].static_key` is the literal marker `'published'`,
69
69
  * not the key it describes.
70
70
  *
71
71
  * What an agent-facing tool *may* do across machines is bounded and stated: ask the machines the relay
72
72
  * leased a task to run one argv inside their own allow-lists, in their own copy of the project,
73
- * read-only unless `write: true` names a `write_scope`, and report what they said — refusals
73
+ * read-only unless `write: true` names a `write_scope`, and report what they said - refusals
74
74
  * included, as values.
75
75
  *
76
76
  * # `@deepseek-ai/dsh-tools` and the offline fallback
@@ -93,6 +93,11 @@ import { createHash } from 'node:crypto';
93
93
  import { promises as fs } from 'node:fs';
94
94
  import os from 'node:os';
95
95
  import path from 'node:path';
96
+ // The individual memory layer (v0.9.6). Read through the agent's own module rather than re-parsing the
97
+ // file here: the reader carries the rules that matter -- newest-first by file position, a malformed line
98
+ // skipped rather than fatal, and the rotated generation reachable -- and a second implementation would
99
+ // drift from the writer's shape without anything failing.
100
+ import { readMemorySync } from '../agent/memory.mjs';
96
101
 
97
102
  // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
98
103
  // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
@@ -180,7 +185,7 @@ const MAX_STAGES = 16;
180
185
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
181
186
  * overrides it at runtime for tests and packagers.
182
187
  */
183
- const PLUGIN_VERSION = '0.9.5';
188
+ const PLUGIN_VERSION = '0.9.6';
184
189
 
185
190
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
186
191
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -227,8 +232,8 @@ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
227
232
  * `w2m_status` as message prefixes.
228
233
  *
229
234
  * A code is added only when it names a *distinct remedy*. `W2M_CONFIG` still covers a malformed
230
- * setting at large; the credential gate below has its own code because its remedy — set
231
- * `operatorToken` — is unlike any other configuration fault's.
235
+ * setting at large; the credential gate below has its own code because its remedy - set
236
+ * `operatorToken` - is unlike any other configuration fault's.
232
237
  */
233
238
  export const REFUSAL_CODE = Object.freeze({
234
239
  /** The dispatch pre-flight found the command outside this plugin's `allowedCommands`. */
@@ -267,7 +272,7 @@ export const REFUSAL_CODE = Object.freeze({
267
272
  /** Storing a result frame failed, so the result exists only on the sender. */
268
273
  P2P_INBOX_WRITE_FAILED: 'P2P_INBOX_WRITE_FAILED',
269
274
  /**
270
- * The composer's selected target routed this dispatch. Not a refusal — a named *reason*, so the
275
+ * The composer's selected target routed this dispatch. Not a refusal - a named *reason*, so the
271
276
  * routing decision is greppable in the answer instead of being a bare `applied: true`.
272
277
  */
273
278
  SELECTION_APPLIED: 'SELECTION_APPLIED',
@@ -577,7 +582,7 @@ function readConfig(config = {}) {
577
582
  /**
578
583
  * §7: where the relay address came from.
579
584
  *
580
- * The order is `rabbitUrl` → `W2M_RABBIT_URL` → the shared server, and the *source* is carried out
585
+ * The order is `rabbitUrl` 鈫?`W2M_RABBIT_URL` 鈫?the shared server, and the *source* is carried out
581
586
  * of here rather than re-derived at report time, because "which of the three won" is exactly the
582
587
  * question a cross-network deployment gets wrong: a machine that silently talked to the shared
583
588
  * server instead of the operator's own relay looks identical to a working one until it dispatches
@@ -709,7 +714,7 @@ function readConfig(config = {}) {
709
714
  }
710
715
 
711
716
  /**
712
- * Resolve `p2pMode` from the config object, then `W2M_P2P_MODE`, then the default — loudly.
717
+ * Resolve `p2pMode` from the config object, then `W2M_P2P_MODE`, then the default - loudly.
713
718
  *
714
719
  * A value that is present and wrong is a `W2M_CONFIG` error naming the setting: falling back to
715
720
  * `auto` would dispatch work over a path the operator explicitly asked not to use, and falling back
@@ -717,7 +722,7 @@ function readConfig(config = {}) {
717
722
  * decisions the operator has to make.
718
723
  *
719
724
  * "Present" is decided by the *key*, not by truthiness. A profile patch that writes `p2pMode:` with
720
- * nothing after it hands this function `null`, and that is a value somebody typed — collapsing it
725
+ * nothing after it hands this function `null`, and that is a value somebody typed - collapsing it
721
726
  * into "the key is absent" is exactly the silent default this function exists to refuse. An unset key
722
727
  * and a blank `W2M_P2P_MODE` are the only two shapes that mean "nobody chose", and only the second is
723
728
  * treated as absent because an unset environment variable is indistinguishable from an empty one.
@@ -753,12 +758,12 @@ function resolveP2PMode(config) {
753
758
  * Every entry is validated here, before the node is built, because the alternative is a tick several
754
759
  * seconds later inside the node's announce loop: an unusable STUN entry is a configuration mistake,
755
760
  * and this project reports those at the call site rather than as a degraded status field. The rule
756
- * itself is `parseServer` from `src/agent/stun.mjs` — the same function the query path will use, so
761
+ * itself is `parseServer` from `src/agent/stun.mjs` - the same function the query path will use, so
757
762
  * "valid here" and "usable there" cannot drift.
758
763
  *
759
764
  * An **empty list is the default**, not an error. `stunServers: []` is what the bundled
760
765
  * `cordis.patch.yml` carries (a key that documents itself and names no server), and a profile that
761
- * starts from that file must not fail to load. `null` entries are dropped for the same reason — a
766
+ * starts from that file must not fail to load. `null` entries are dropped for the same reason - a
762
767
  * YAML list with a blank item is the same statement as a blank item in a profile patch. A list whose
763
768
  * entries are *wrong* is still refused: there is a difference between "nothing named" and "the wrong
764
769
  * thing named", and only the second is a mistake.
@@ -985,7 +990,7 @@ function resolveProfileDir(config) {
985
990
  /**
986
991
  * Require the Rabbit URL, naming the setting when it is absent.
987
992
  *
988
- * With §7's shared-server default this can no longer be reached by "the operator set nothing" — an
993
+ * With §7's shared-server default this can no longer be reached by "the operator set nothing" - an
989
994
  * unset `rabbitUrl` resolves to {@link SHARED_SERVER}. It stays as the last-resort guard so that a
990
995
  * future edit which clears the field fails with a sentence naming it instead of an `Invalid URL`
991
996
  * from somewhere deep in `fetch`.
@@ -1042,7 +1047,7 @@ function devicePathFor(cfg) {
1042
1047
  *
1043
1048
  * {@link readDevice} answers from a `stateDir` and is the tool-facing reader; this one adds the
1044
1049
  * `DSH_HOME` fallback (an unset `stateDir` is the documented default layout, not an error) and turns
1045
- * every failure into a `machine_id` of null plus an `error` — a machine whose identity cannot be read
1050
+ * every failure into a `machine_id` of null plus an `error` - a machine whose identity cannot be read
1046
1051
  * must still be able to dispatch, because the relay path does not need an identity at all.
1047
1052
  *
1048
1053
  * @param {ReturnType<typeof readConfig>} cfg
@@ -1082,8 +1087,8 @@ function safeFilePart(value) {
1082
1087
  *
1083
1088
  * Deliberately *not* the relay's own `jcs` (that would make this file depend on relay internals for
1084
1089
  * a two-line need). The digest here is only an integrity check of what this process stored, so the
1085
- * rule is local by definition; the value that has to match another process byte for byte — the
1086
- * dedupe key — is the one imported from the relay instead.
1090
+ * rule is local by definition; the value that has to match another process byte for byte - the
1091
+ * dedupe key - is the one imported from the relay instead.
1087
1092
  *
1088
1093
  * @param {unknown} value
1089
1094
  * @returns {string}
@@ -1099,9 +1104,9 @@ function canonicalJson(value) {
1099
1104
  /**
1100
1105
  * Does `frame` name both halves of the inbox key?
1101
1106
  *
1102
- * A frame without a `task_id` or without a `machine_id` cannot be stored or acknowledged — the
1107
+ * A frame without a `task_id` or without a `machine_id` cannot be stored or acknowledged - the
1103
1108
  * acknowledgement is keyed by `task_id`, and two machines answering the same task must not overwrite
1104
- * each other — so it is refused by name instead of being written somewhere surprising.
1109
+ * each other - so it is refused by name instead of being written somewhere surprising.
1105
1110
  *
1106
1111
  * Both halves of the refusal are returned: `reason` is the `CODE: sentence` string this has always
1107
1112
  * produced (it is what `w2m_status` shows), `reason_code` is the same code as a field, so a reader
@@ -1159,7 +1164,7 @@ async function writeFileAtomic(dir, file, text) {
1159
1164
  * Count the stored result frames.
1160
1165
  *
1161
1166
  * Depth is read from the directory rather than tracked in memory, so it answers the question that
1162
- * actually matters — "is there anything here I have not consumed" — across a plugin reload. A
1167
+ * actually matters - "is there anything here I have not consumed" - across a plugin reload. A
1163
1168
  * missing directory is depth 0, not an error: the inbox exists only once something has arrived.
1164
1169
  *
1165
1170
  * @param {string|null} dir
@@ -1233,7 +1238,7 @@ function createP2PTransport(cfg) {
1233
1238
  *
1234
1239
  * A dispatcher behind a symmetric NAT (CGNAT, in practice) can send all it likes and still be heard
1235
1240
  * by nobody: its outbound HELLO opens its own mapping, but the peer's filter stays shut until
1236
- * *something of the peer's* has gone the other way. So the executor has to dial first — and to dial
1241
+ * *something of the peer's* has gone the other way. So the executor has to dial first - and to dial
1237
1242
  * it needs two things the offer itself can carry: where the dispatcher says it is, and which punch
1238
1243
  * to join. Without them the executor has only the relay lookup, which is a second opinion about the
1239
1244
  * same machine that answers 404 for a dispatcher whose announcement has just expired.
@@ -1241,14 +1246,14 @@ function createP2PTransport(cfg) {
1241
1246
  * WHY A PUNCH ID RATHER THAN A SESSION ID
1242
1247
  *
1243
1248
  * The session id cannot be known when this request is written: one dispatch leases one task to
1244
- * several machines, and the relay decides which — in the same request that mints the task id. A
1249
+ * several machines, and the relay decides which - in the same request that mints the task id. A
1245
1250
  * single session shared by those machines would be a defect, not a simplification: on the
1246
1251
  * dispatcher's own node the second machine's HELLO would look like a re-acknowledgement of the
1247
1252
  * first machine's live channel, so one of them would have its result delivered to the other's
1248
1253
  * address. The block therefore publishes a per-dispatch *punch id*, and both ends derive the same
1249
1254
  * per-machine session from it (`punchSession` in `src/agent/p2p.mjs`): the dispatcher from the
1250
- * lease, the executor from the offer plus its own machine id. The pushed frame — which is addressed
1251
- * to one machine, so the value *is* known — carries the derived `session` as well.
1255
+ * lease, the executor from the offer plus its own machine id. The pushed frame - which is addressed
1256
+ * to one machine, so the value *is* known - carries the derived `session` as well.
1252
1257
  *
1253
1258
  * COMPATIBILITY
1254
1259
  *
@@ -1264,11 +1269,11 @@ function createP2PTransport(cfg) {
1264
1269
  * WHY A MAP RATHER THAN ONE KEY
1265
1270
  *
1266
1271
  * The executor needs the *dispatcher's* key before it can dial back, and the dispatcher needs the
1267
- * *executor's* key before it can push — and at dispatch time the relay has not yet said which
1272
+ * *executor's* key before it can push - and at dispatch time the relay has not yet said which
1268
1273
  * machines the task went to (`leases` come back in the same response that creates it). So the block
1269
1274
  * carries this machine's own key, which is known before the request is written, and
1270
1275
  * {@link offerP2PFor} adds the target's once the lease names it. Reading the field is therefore
1271
- * "look up the machine you are about to talk to", never "assume the only entry is the peer" — a
1276
+ * "look up the machine you are about to talk to", never "assume the only entry is the peer" - a
1272
1277
  * machine that receives an offer for a fleet of three has three entries and uses one.
1273
1278
  *
1274
1279
  * What that buys, in the document's own terms: no `[Peer]` block for anyone to fill in. A machine
@@ -1295,7 +1300,7 @@ function buildP2POfferBlock(cfg, runtime) {
1295
1300
  block.candidates = candidates.map((candidate) => ({ address: candidate.address, port: candidate.port }));
1296
1301
  }
1297
1302
  // This machine's own static public key. Absent (not null) when there is none, so the wire stays
1298
- // byte-identical to v0.5.x for a machine that has no key — an additive change has to be absent to
1303
+ // byte-identical to v0.5.x for a machine that has no key - an additive change has to be absent to
1299
1304
  // be additive.
1300
1305
  const own = runtime?.encryption?.public_key;
1301
1306
  const machineId = runtime?.machineId;
@@ -1311,7 +1316,7 @@ function buildP2POfferBlock(cfg, runtime) {
1311
1316
  * The added `session` is what makes the push and a reverse dial one punch rather than two: an
1312
1317
  * executor that received the offer over a channel may still have to dial back (the dispatcher closes
1313
1318
  * the channel it pushed over, immediately after the push), and a dial carrying a session the
1314
- * dispatcher is not filtering for would be answered by its `accept()` at best — a second session,
1319
+ * dispatcher is not filtering for would be answered by its `accept()` at best - a second session,
1315
1320
  * a second channel, and two live sessions for one task where one would do.
1316
1321
  *
1317
1322
  * v0.6.0 adds the *target's* key to `static_keys` when the caller supplies one, which is the half of
@@ -1343,8 +1348,7 @@ function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
1343
1348
  *
1344
1349
  * The executor decides whether an offer is a first delivery or a duplicate on `dedupe_key` +
1345
1350
  * `attempt` (§6), and the relay emits its own SSE offer with those exact values. A push that
1346
- * disagreed by one character would make the machine execute the task twice — once from each path —
1347
- * so the dedupe key is computed with the relay's own {@link computeDedupeKey}/{@link computeCommandHash},
1351
+ * disagreed by one character would make the machine execute the task twice - once from each path - * so the dedupe key is computed with the relay's own {@link computeDedupeKey}/{@link computeCommandHash},
1348
1352
  * from the same `task_id`, `index`, `command_hash` inputs and `base_tree` the task was created with,
1349
1353
  * and `machine_id`/`index`/`attempt` come from the lease rather than from the caller's arguments.
1350
1354
  *
@@ -1419,7 +1423,7 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = nu
1419
1423
  /**
1420
1424
  * Push one offer over the direct path. Best effort by construction: nothing here throws.
1421
1425
  *
1422
- * The session comes from the frame's own `p2p.session` — minted per machine by
1426
+ * The session comes from the frame's own `p2p.session` - minted per machine by
1423
1427
  * {@link offerP2PFor} from the punch id the relay is publishing for this same task. Dialling with a
1424
1428
  * session of the node's own invention would leave the two ends on two sessions, and an executor
1425
1429
  * whose reverse dial happened to arrive first would open a *second* channel beside this one instead of
@@ -1449,7 +1453,7 @@ async function pushOffer(node, frame, crypto = {}) {
1449
1453
  }
1450
1454
  // The dial's own options, built here rather than inline because the *presence* of `crypto` is the
1451
1455
  // decision: `P2PNode.dial` arms a handshake only when this dispatcher holds a key for that peer, and
1452
- // a peer with no published key is dialled exactly as v0.5.x dialled it — plaintext, recorded, and
1456
+ // a peer with no published key is dialled exactly as v0.5.x dialled it - plaintext, recorded, and
1453
1457
  // never a failure. That is §4's fallback clause applied to the push: a fleet must never lose a
1454
1458
  // machine to a security upgrade. It is not a silent downgrade, because the peer never offered a key
1455
1459
  // to downgrade *from*; a peer whose key we hold and whose handshake fails produces `ok: false` and
@@ -1505,14 +1509,14 @@ async function pushOffer(node, frame, crypto = {}) {
1505
1509
  *
1506
1510
  * Called after the relay has created the task, so the relay's own SSE offer has already gone out:
1507
1511
  * this is an *additional* delivery over the faster path, and the relay's copy is what makes a failed
1508
- * push harmless. Only leases in a runnable state are pushed — `refused` and `observing` machines
1512
+ * push harmless. Only leases in a runnable state are pushed - `refused` and `observing` machines
1509
1513
  * were never handed the command, and sending them an offer would be asking them to run work the
1510
1514
  * relay decided they must not run.
1511
1515
  *
1512
1516
  * v0.6.0: each target's static key is fetched from the relay's peer record before the push, which is
1513
1517
  * the one moment the dispatcher learns the key of a machine it did not already know. It is fetched
1514
1518
  * **here** rather than sourced from the stored `p2p` block because the block is written *before* the
1515
- * relay decides which machines the task goes to — the offer that carries the key is the one addressed
1519
+ * relay decides which machines the task goes to - the offer that carries the key is the one addressed
1516
1520
  * to the machine that key belongs to. A missing key is not an error: the push proceeds and the
1517
1521
  * executor records `P2P_CRYPTO_NO_KEY` for its plaintext channel, which is the documented outcome for
1518
1522
  * a peer that never announced.
@@ -1520,7 +1524,7 @@ async function pushOffer(node, frame, crypto = {}) {
1520
1524
  * @param {P2PNode} node
1521
1525
  * @param {object} options
1522
1526
  * @returns {Promise<Array<object>>} One entry per lease considered, in lease order. A machine that was
1523
- * not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it — this
1527
+ * not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it - this
1524
1528
  * block is a tool result, so a bare boolean here would be exactly the reduction the verdicts refuse.
1525
1529
  */
1526
1530
  async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
@@ -1574,7 +1578,7 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId, getJ
1574
1578
  // logs side by side. `null` when there was nothing to punch over (a v0.4.x-shaped frame).
1575
1579
  session: result.session,
1576
1580
  // True when the executor's dial got there first and this push travelled over the channel that
1577
- // dial opened — a two-way punch that worked, reported as such rather than as a plain push.
1581
+ // dial opened - a two-way punch that worked, reported as such rather than as a plain push.
1578
1582
  reused: result.reused === true,
1579
1583
  // v0.6.0: whether the offer actually travelled sealed, and why not when it did not. Reported
1580
1584
  // per machine because it is a per-machine fact: one peer can be encrypted and the next one a
@@ -1618,19 +1622,19 @@ async function readPeerStaticKey(getJson, machineId) {
1618
1622
  * WHEN THE NODE STARTS, AND WHY IT IS NOT `apply()`
1619
1623
  *
1620
1624
  * The v0.4.0 contract says the node is "started in `apply()`". It is not, and this is the one place
1621
- * this file departs from the contract's letter rather than its intent — reported as a deviation, not
1625
+ * this file departs from the contract's letter rather than its intent - reported as a deviation, not
1622
1626
  * settled quietly here. What *is* kept is everything the sentence is about: the direct path is owned
1623
1627
  * for the plugin's whole lifetime, it is disposed by a `ctx.effect` disposer, and a failure to start
1624
1628
  * is a field in `w2m_status` rather than an exception at load.
1625
1629
  *
1626
1630
  * The reason for the change is what "start" costs. Starting this node binds a UDP socket and kicks
1627
- * off a STUN sweep against four servers — network traffic with a multi-second tail — for a machine
1631
+ * off a STUN sweep against four servers - network traffic with a multi-second tail - for a machine
1628
1632
  * that may never dispatch a task in that session. `apply()` runs on every session in a host that
1629
1633
  * mounts this plugin, so an unconditional start is a rendezvous per session, and it puts traffic
1630
1634
  * into every test and every host whose runtime is deterministic about what it sends. `ensureStarted()`
1631
1635
  * therefore does the work the first time a tool actually needs the direct path: a dispatch
1632
1636
  * (`w2m_run`) or a question about it (`w2m_status`). A machine that never dispatches never punches,
1633
- * which is also the more honest reading of §1 — "never dials" is the property that matters, and this
1637
+ * which is also the more honest reading of §1 - "never dials" is the property that matters, and this
1634
1638
  * is closer to it than an announce nobody asked for.
1635
1639
  *
1636
1640
  * @param {object|null} ctx Cordis context; `ctx.effect` is used when present.
@@ -1674,7 +1678,7 @@ function createP2PRuntime(ctx, cfg, block = {}) {
1674
1678
  *
1675
1679
  * Assigned before the relay-mode early return so the tools can call it unconditionally: a caller
1676
1680
  * asks "is the direct path ready" and gets an answer either way, instead of having to know which
1677
- * mode it is in — the defect this shape fixes was a `relay`-mode dispatch calling a method that did
1681
+ * mode it is in - the defect this shape fixes was a `relay`-mode dispatch calling a method that did
1678
1682
  * not exist, which is a crash in the mode that is supposed to be the safe one.
1679
1683
  *
1680
1684
  * @type {() => Promise<object>}
@@ -1722,7 +1726,7 @@ function createP2PRuntime(ctx, cfg, block = {}) {
1722
1726
  *
1723
1727
  * Never throws. A caller cannot fix an unreadable key file from a tool call, and a dispatch that
1724
1728
  * refused because a *security upgrade* could not complete would be exactly the fleet-losing failure
1725
- * §4 forbids — so the failure becomes a reason string, the node runs without a key, and every offer
1729
+ * §4 forbids - so the failure becomes a reason string, the node runs without a key, and every offer
1726
1730
  * this machine publishes is plainly labelled as having none.
1727
1731
  *
1728
1732
  * @param {object|null} injected Test seam: a `DeviceKey`, or null to use the file.
@@ -1823,7 +1827,7 @@ async function initP2P(ctx, cfg, runtime, block) {
1823
1827
  /**
1824
1828
  * Ownership: one node, one effect, one disposer.
1825
1829
  *
1826
- * Registered *here*, after the node exists, rather than in `createP2PRuntime` — an effect that owns
1830
+ * Registered *here*, after the node exists, rather than in `createP2PRuntime` - an effect that owns
1827
1831
  * nothing is a disposer that releases nothing, and `relay` mode and a machine with no identity must
1828
1832
  * leave the host's effect list exactly as they found it. A host that provides no `ctx.effect` (an
1829
1833
  * older Cordis, or a bare test context) still gets a working node: the node itself is asked not to
@@ -1832,7 +1836,7 @@ async function initP2P(ctx, cfg, runtime, block) {
1832
1836
  * default.
1833
1837
  *
1834
1838
  * v0.5.10: `node.unref()` is a real call, not a description. Until it was added this comment
1835
- * claimed an `unref()` that existed nowhere in the plugin or the node — the socket went on holding
1839
+ * claimed an `unref()` that existed nowhere in the plugin or the node - the socket went on holding
1836
1840
  * the event loop, so a host without `ctx.effect` that started a node never exited (found by an
1837
1841
  * independent verifier against the `w2m_status` test's bare context).
1838
1842
  */
@@ -1863,13 +1867,13 @@ async function initP2P(ctx, cfg, runtime, block) {
1863
1867
  * Store one JSON frame that arrived over a direct channel, and acknowledge it.
1864
1868
  *
1865
1869
  * The writer is deliberately strict. A frame that is not a JSON object, or that names no
1866
- * `task_id`/`machine_id`, is refused and **nothing** is written — an inbox file is the only durable
1870
+ * `task_id`/`machine_id`, is refused and **nothing** is written - an inbox file is the only durable
1867
1871
  * trace of a peer's result, and a file whose name does not identify its contents is worse than no
1868
1872
  * file, because the next reader cannot tell a partial result from a complete one.
1869
1873
  *
1870
1874
  * `task.result` is the frame this exists for; `result.ack` and anything else a peer might send are
1871
1875
  * counted and ignored rather than treated as faults. The acknowledgement goes back on the same
1872
- * channel the frame arrived on, and a failed acknowledgement is recorded — the sender retries (the
1876
+ * channel the frame arrived on, and a failed acknowledgement is recorded - the sender retries (the
1873
1877
  * channel is reliable) and the file is already on disk, so a lost ack must not lose the result.
1874
1878
  *
1875
1879
  * @param {object} runtime
@@ -1930,7 +1934,7 @@ async function handleP2PMessage(runtime, event) {
1930
1934
  task_id: frame.task_id,
1931
1935
  frame,
1932
1936
  // A digest of the stored copy, so a reader can tell "the file changed under me" from "the peer
1933
- // sent something else". Not a signature — the channel is not authenticated at this layer.
1937
+ // sent something else". Not a signature - the channel is not authenticated at this layer.
1934
1938
  frame_sha256: createHash('sha256').update(canonicalJson(frame)).digest('hex'),
1935
1939
  };
1936
1940
  const file = path.join(
@@ -1970,7 +1974,7 @@ async function handleP2PMessage(runtime, event) {
1970
1974
  *
1971
1975
  * Every field is present on every call, including when the direct path is off: a reader must be able
1972
1976
  * to tell "relay mode" from "p2p was requested and could not start" without comparing two calls.
1973
- * `null` is used for "never measured", never 0 — the same rule the rest of this file follows for RTT.
1977
+ * `null` is used for "never measured", never 0 - the same rule the rest of this file follows for RTT.
1974
1978
  *
1975
1979
  * @param {ReturnType<typeof readConfig>} cfg
1976
1980
  * @param {object} runtime
@@ -2012,7 +2016,7 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
2012
2016
  // v0.6.0: the crypto facts, named one by one rather than mirrored wholesale. `enabled` is about
2013
2017
  // *this* machine's key; `encrypted_channels` and `plaintext_channels` are about the channels it
2014
2018
  // has actually made, and they can both be non-zero on a mixed fleet. `error` is the reason there
2015
- // is no key when there is none — a reader has to be able to tell "this build has no crypto" from
2019
+ // is no key when there is none - a reader has to be able to tell "this build has no crypto" from
2016
2020
  // "the key file is unreadable" without reading a log.
2017
2021
  encryption: {
2018
2022
  enabled: runtime.encryption?.enabled === true,
@@ -2504,7 +2508,7 @@ async function request(options) {
2504
2508
  }
2505
2509
 
2506
2510
  // §2: base + path, joined by hand. `new URL(pathname, base)` would resolve against the origin
2507
- // and drop a deployment sub-path (`https://h/w2m` + `/v1/task` 鈫?`https://h/v1/task` 鈫?404).
2511
+ // and drop a deployment sub-path (`https://h/w2m` + `/v1/task` —?`https://h/v1/task` —?404).
2508
2512
  const target = toRequestUrl(joinUrl(rabbitUrl, pathname));
2509
2513
  for (const [key, value] of Object.entries(query ?? {})) {
2510
2514
  if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
@@ -2584,8 +2588,8 @@ function endpointOf(target) {
2584
2588
  * Two sources, and the distinction is the point. An explicit `stateDir` is the operator pointing at
2585
2589
  * a pairing, so a missing or unreadable `device.json` there is **named as the fault** rather than
2586
2590
  * being turned into a mystery `401`. With no `stateDir`, this machine's own identity lives where the
2587
- * agent writes it by default — `$DSH_HOME/xclient/device.json`, the same file {@link readLocalIdentity}
2588
- * reads — and the calls go out with it. v0.4.0 made that fallback load-bearing rather than optional:
2591
+ * agent writes it by default - `$DSH_HOME/xclient/device.json`, the same file {@link readLocalIdentity}
2592
+ * reads - and the calls go out with it. v0.4.0 made that fallback load-bearing rather than optional:
2589
2593
  * every P2P announcement is authenticated as the announcing device, so a machine whose identity was
2590
2594
  * only reachable through `DSH_HOME` would have had a permanently unauthenticated rendezvous and a
2591
2595
  * direct path that never worked, while the relay path kept working and hid the reason.
@@ -2655,8 +2659,7 @@ async function readDevice(cfg) {
2655
2659
  /**
2656
2660
  * Require a `device_token`, naming where it was looked for when it is absent.
2657
2661
  *
2658
- * The `code` stays `W2M_NO_TOKEN` for every case — one class, "this machine cannot authenticate" —
2659
- * while `reason_code` names which of the three it is, because the three have different remedies: pair
2662
+ * The `code` stays `W2M_NO_TOKEN` for every case - one class, "this machine cannot authenticate" - * while `reason_code` names which of the three it is, because the three have different remedies: pair
2660
2663
  * the agent, repair the file, or finish a pairing that never completed.
2661
2664
  *
2662
2665
  * @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
@@ -2817,13 +2820,13 @@ async function resolveTreeFingerprint(projectDir, timeoutMs) {
2817
2820
  * resolving a path, and a pre-flight that strips the path can only ever compare names, so it would
2818
2821
  * wave `E:\Temp\evil\node.exe` through while the agent refused it. Passing the argv through
2819
2822
  * unchanged is what keeps the two ends one rule. What the shared matcher then resolves, it resolves
2820
- * against **this** process's environment — the dispatcher's PATH — which is a real limit of a
2823
+ * against **this** process's environment - the dispatcher's PATH - which is a real limit of a
2821
2824
  * pre-flight (the executing machine's PATH is the one that will decide), and the reason the refusal
2822
2825
  * that matters is the agent's own.
2823
2826
  *
2824
2827
  * A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
2825
2828
  * refusal under two spellings) and `detail` says what would change it. The two ways this control
2826
- * refuses are kept apart — a command outside the list and a list that cannot be parsed have
2829
+ * refuses are kept apart - a command outside the list and a list that cannot be parsed have
2827
2830
  * different remedies, and sharing one code would send the reader to the wrong one.
2828
2831
  *
2829
2832
  * @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
@@ -2903,7 +2906,7 @@ function compactEnvelope(raw, comparable) {
2903
2906
  function excerpt(value) {
2904
2907
  const buffer = Buffer.from(value, 'utf8');
2905
2908
  if (buffer.byteLength <= MAX_EXCERPT_BYTES) return value;
2906
- return `${buffer.subarray(0, MAX_EXCERPT_BYTES).toString('utf8')}\n - truncated at ${MAX_EXCERPT_BYTES} bytes]`;
2909
+ return `${buffer.subarray(0, MAX_EXCERPT_BYTES).toString('utf8')}\n - …[truncated at ${MAX_EXCERPT_BYTES} bytes]`;
2907
2910
  }
2908
2911
 
2909
2912
  /**
@@ -2952,7 +2955,7 @@ const OPTIONAL_FIELDS = new Set([
2952
2955
  'lockfiles', 'unpinned_deps', 'submodules', 'lfs', 'signal',
2953
2956
  // v0.4.0 §5: how the envelope travelled, and the path facts that came with it. Optional because a
2954
2957
  // v0.3.9 agent sends neither and must keep working unchanged; here rather than in
2955
- // `COMPARABLE_FIELDS` because **a path is not a result** — two byte-identical runs on two machines
2958
+ // `COMPARABLE_FIELDS` because **a path is not a result** - two byte-identical runs on two machines
2956
2959
  // must stay `consistent` when one of them punched a hole and the other used the relay.
2957
2960
  'transport', 'p2p',
2958
2961
  ]);
@@ -3164,7 +3167,7 @@ export async function apply(ctx, config = {}) {
3164
3167
  //
3165
3168
  // Composed here, next to the updater's own `ctx.effect`, and for the same reason: a socket and a
3166
3169
  // refresh timer have to be released when the plugin is unloaded, and an effect disposer is the
3167
- // only mechanism Cordis gives for that. Creating it never throws — a machine that cannot punch
3170
+ // only mechanism Cordis gives for that. Creating it never throws - a machine that cannot punch
3168
3171
  // still has to dispatch, and `w2m_status` is where the failure is reported.
3169
3172
  const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
3170
3173
 
@@ -3180,14 +3183,13 @@ export async function apply(ctx, config = {}) {
3180
3183
  });
3181
3184
 
3182
3185
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3183
- // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence —
3184
- // which is the honest place for it, since a host that has no Connection at all is simply not a web
3186
+ // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
3185
3187
  // composition and does not need a warning on every headless run.
3186
3188
  //
3187
3189
  // Nothing here reads the service: `registerTargetRoutes` declares the optional dependency with
3188
3190
  // `ctx.inject` and attaches from that callback (the comment there records why a plain
3189
3191
  // `ctx.connection` read fails the whole entry, and why the read cannot be synchronous anyway). The
3190
- // one mismatch worth a log line — a Connection that carries no fetch registry — is logged where it
3192
+ // one mismatch worth a log line - a Connection that carries no fetch registry - is logged where it
3191
3193
  // is observed, inside that callback, so this call returns a status object that stays live.
3192
3194
  const targetRoutes = registerTargetRoutes(ctx, {
3193
3195
  cfg: () => cfg,
@@ -3580,7 +3582,7 @@ export async function apply(ctx, config = {}) {
3580
3582
  //
3581
3583
  // The precedence rule is the whole contract, and it is one line: **the caller's own fields
3582
3584
  // always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
3583
- // argument — so a caller that named a machine (which, for this tool, means
3585
+ // argument - so a caller that named a machine (which, for this tool, means
3584
3586
  // `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
3585
3587
  // the selection. Nothing already published changes meaning: the selection only fills fields
3586
3588
  // that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
@@ -3605,7 +3607,7 @@ export async function apply(ctx, config = {}) {
3605
3607
  * `applied` alone conflates three different situations an operator has to tell apart: nothing
3606
3608
  * was selected, the selection was overridden by the call's own `executor_machine_id`, or a
3607
3609
  * selection exists that names no machine at all and therefore routed nothing. The last one is
3608
- * the one that used to be invisible — a stored selection with no `machine_id` produces
3610
+ * the one that used to be invisible - a stored selection with no `machine_id` produces
3609
3611
  * `applied: false` and nothing else, which looks exactly like "the whole fleet ran it".
3610
3612
  */
3611
3613
  const selectionReason = selection === null
@@ -3692,14 +3694,14 @@ export async function apply(ctx, config = {}) {
3692
3694
 
3693
3695
  // §7: the two routing facts the offer carries to the machine that has to run the command.
3694
3696
  //
3695
- // `origin_machine_id` is *this* machine's id from `device.json` — where the work was typed, not
3696
- // where it runs — and it is omitted entirely (not sent as null) when this machine has no
3697
+ // `origin_machine_id` is *this* machine's id from `device.json` - where the work was typed, not
3698
+ // where it runs - and it is omitted entirely (not sent as null) when this machine has no
3697
3699
  // identity, because the relay's own validation refuses an empty string and "unknown origin" is
3698
3700
  // what a missing field already means on the wire.
3699
3701
  //
3700
3702
  // The identity comes from the runtime, which resolves it off the dispatch path; a dispatch that
3701
3703
  // arrives before that one small read has finished waits for it rather than racing it. The wait
3702
- // is bounded by the identity read itself and can never reject — see `createP2PRuntime`. This is
3704
+ // is bounded by the identity read itself and can never reject - see `createP2PRuntime`. This is
3703
3705
  // also the first use of the direct path, so it is where the node is started.
3704
3706
  await p2p.ensureStarted();
3705
3707
  const originMachineId =
@@ -3742,7 +3744,7 @@ export async function apply(ctx, config = {}) {
3742
3744
  // above refuses the combination, so this can never silently mean nothing.
3743
3745
  ...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
3744
3746
  // v0.4.0: stated on every task, so `w2m_wait`'s `transport` and a machine's report can say
3745
- // which path the dispatcher expected to use — and so the executor in `direct` mode knows it
3747
+ // which path the dispatcher expected to use - and so the executor in `direct` mode knows it
3746
3748
  // must refuse an offer that did not arrive over the direct path.
3747
3749
  ...(originMachineId !== null ? { origin_machine_id: originMachineId } : {}),
3748
3750
  p2p: p2pOffer,
@@ -3788,7 +3790,7 @@ export async function apply(ctx, config = {}) {
3788
3790
  * so a push that fails, a peer that never announced and a socket that was never bound all leave
3789
3791
  * exactly the same outcome: the relay's own SSE offer is the delivery, and the machine's
3790
3792
  * `transport` says which path won. A failed push is therefore **not** an error and never fails
3791
- * this tool — but it is not hidden either, because `direct_pushes` names every attempt and the
3793
+ * this tool - but it is not hidden either, because `direct_pushes` names every attempt and the
3792
3794
  * reason it did not land.
3793
3795
  *
3794
3796
  * `relay` mode and a machine with no direct path both report `attempted: 0`, which is the
@@ -3823,7 +3825,7 @@ export async function apply(ctx, config = {}) {
3823
3825
  failed: pushes.filter((entry) => !entry.ok).length,
3824
3826
  pushes,
3825
3827
  // How many of the machines that were reached were reached over a channel *this* machine
3826
- // did not open — the executor dialled first, and the push landed on that path. Zero on a
3828
+ // did not open - the executor dialled first, and the push landed on that path. Zero on a
3827
3829
  // fleet where only the dispatcher initiates, which is what makes the two-way punch
3828
3830
  // visible in one dispatch's own answer instead of only in the ledger.
3829
3831
  reused_channels: pushes.filter((entry) => entry.reused === true).length,
@@ -4023,7 +4025,7 @@ export async function apply(ctx, config = {}) {
4023
4025
  * v0.4.0 §5: which path this machine's result travelled, and the path facts that came
4024
4026
  * with it. Read from the envelope because that is where the agent writes them, with the
4025
4027
  * per-machine record as the fallback (`GET /v1/tasks/{id}` copies both onto the machine
4026
- * entry as well). `null` means the machine did not say — which is exactly what a
4028
+ * entry as well). `null` means the machine did not say - which is exactly what a
4027
4029
  * v0.3.9 agent sends, so a missing value must not be dressed up as `'relay'`.
4028
4030
  */
4029
4031
  transport: envelope?.transport ?? machine?.transport ?? null,
@@ -4223,7 +4225,7 @@ export async function apply(ctx, config = {}) {
4223
4225
  const rtt = await readRtt(cfg);
4224
4226
  // §7: this tool is one of the two places the direct path is asked about, so it is one of the
4225
4227
  // two places the node is started. Never throws, and reports `enabled: false`/`running: false`
4226
- // rather than omitting itself — a reader has to be able to tell "relay mode" from "p2p was
4228
+ // rather than omitting itself - a reader has to be able to tell "relay mode" from "p2p was
4227
4229
  // requested and did not start".
4228
4230
  await p2p.ensureStarted();
4229
4231
  const p2pStatus = await readP2PStatus(cfg, p2p, Number.isInteger(relay.peers_announced) ? relay.peers_announced : null);
@@ -4303,8 +4305,8 @@ export async function apply(ctx, config = {}) {
4303
4305
  /**
4304
4306
  * v0.6.1: the composer's target control and the selections it holds.
4305
4307
  *
4306
- * Reported here because "the control is not in my composer" has exactly two causes — the
4307
- * host has no fetch-route registry, or the browser half is not loaded — and this is the
4308
+ * Reported here because "the control is not in my composer" has exactly two causes - the
4309
+ * host has no fetch-route registry, or the browser half is not loaded - and this is the
4308
4310
  * one call that can tell them apart from the host side. The selections themselves are
4309
4311
  * counts, not contents: which session picked which machine is the operator's business and
4310
4312
  * nothing the model needs.
@@ -4336,8 +4338,7 @@ export async function apply(ctx, config = {}) {
4336
4338
  * v0.8.5: **say it out loud.**
4337
4339
  *
4338
4340
  * This branch is the whole reason a machine can run v0.8.4 with the account-derived fleet key,
4339
- * hold a valid signed-in account, reach the relay over HTTP 200, and still be in no fleet —
4340
- * with nothing anywhere reporting a problem. The eight tools register, the plugin looks
4341
+ * hold a valid signed-in account, reach the relay over HTTP 200, and still be in no fleet - * with nothing anywhere reporting a problem. The eight tools register, the plugin looks
4341
4342
  * healthy, and the join sequence simply never runs.
4342
4343
  *
4343
4344
  * A silent no-op that looks like success is the worst failure mode this plugin has had. One
@@ -4661,6 +4662,181 @@ export async function apply(ctx, config = {}) {
4661
4662
  },
4662
4663
  presentCall: () => ({ card: 'generic', title: 'w2m stats', kind: 'read', rawInput: {} }),
4663
4664
  }));
4665
+
4666
+ /**
4667
+ * v0.9.6 — the individual memory layer.
4668
+ *
4669
+ * The relay keeps `command_hash`, `stdout_sha256` and `stdout_bytes` and never the text, deliberately:
4670
+ * the ledger is a tamper-evident consistency record, and a relay compromise discloses no command text.
4671
+ * The price is that nothing in the fleet could answer *"what did we already try on this machine, and
4672
+ * what happened?"* -- and that price was paid repeatedly, with the same facts rediscovered from scratch
4673
+ * across a dozen dispatches in one session.
4674
+ *
4675
+ * This tool is the read side of the fix. The write side is `appendMemory`, called by the agent as each
4676
+ * task finishes, in that machine's own state directory.
4677
+ *
4678
+ * ## What this first version does and does not reach
4679
+ *
4680
+ * It reaches **this machine's** memory directly: the file is local, so the read is a file read. It does
4681
+ * **not** yet read another machine's memory, and the reason is structural rather than an omission --
4682
+ * the relay carries no such file, an agent executes a dispatched command inside a sandbox whose writes
4683
+ * are scoped, and pretending otherwise would mean shipping a tool that silently returns nothing for
4684
+ * every machine but one.
4685
+ *
4686
+ * So a query for another machine says exactly that, names where to look instead, and stays useful: the
4687
+ * fleet's roster still comes from the relay, so the answer distinguishes "no such machine" from "that
4688
+ * machine keeps its own memory and this build cannot fetch it".
4689
+ */
4690
+ ctx.tools.register(defineTool({
4691
+ name: 'w2m_memory',
4692
+ description:
4693
+ 'Ask what this machine has already done: its individual memory, one line per finished task, ' +
4694
+ 'recording the command, exit code, duration, transport and the tail of stdout/stderr. Use it ' +
4695
+ 'before re-running work to find out whether it has been tried and what happened. The relay keeps ' +
4696
+ 'only hashes, so this local file is the only place a command\'s output survives. Reads only this ' +
4697
+ 'machine\'s memory; for another machine it reports where that memory lives rather than guessing.',
4698
+ parameters: {
4699
+ machine: {
4700
+ type: 'string',
4701
+ description:
4702
+ 'Which machine\'s memory to read. Defaults to this machine. Naming another machine is ' +
4703
+ 'answered with where its memory is kept, because this build can only read the local file.',
4704
+ },
4705
+ limit: {
4706
+ type: 'number',
4707
+ description: 'How many records to return, newest first. Defaults to 20.',
4708
+ default: 20,
4709
+ },
4710
+ include_previous: {
4711
+ type: 'boolean',
4712
+ description:
4713
+ 'Also read the rotated generation (`memory.jsonl.1`). The log is capped and rotates, so a ' +
4714
+ 'memory question about anything older than the current file needs this.',
4715
+ default: false,
4716
+ },
4717
+ },
4718
+ output: jsonOutput,
4719
+ async execute(args, exec) {
4720
+ throwIfAborted(exec?.signal);
4721
+ const limitRaw = Number(args?.limit);
4722
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
4723
+
4724
+ const requested = typeof args?.machine === 'string' && args.machine.trim() !== '' ? args.machine.trim() : null;
4725
+ const identity = await readLocalIdentity(cfg);
4726
+ const self = identity?.machine_name ?? null;
4727
+ const selfId = identity?.machine_id ?? null;
4728
+
4729
+ // A query about another machine is answered honestly rather than with an empty list, which would
4730
+ // read as "that machine has done nothing" -- the opposite conclusion from "this build cannot see".
4731
+ if (requested !== null && requested !== self && requested !== selfId) {
4732
+ return JSON.stringify(
4733
+ {
4734
+ ok: true,
4735
+ scope: 'remote',
4736
+ requested_machine: requested,
4737
+ self: { machine_id: selfId, machine_name: self },
4738
+ records: null,
4739
+ reason_code: 'MEMORY_REMOTE_NOT_READABLE',
4740
+ detail:
4741
+ `the individual memory of "${requested}" is kept on that machine, in its own state ` +
4742
+ 'directory as memory.jsonl; this build reads only the local file, and the relay never ' +
4743
+ 'receives it',
4744
+ how_to_read_it: [
4745
+ `run \`w2m_run\` on ${requested} with a command that prints the tail of its memory.jsonl`,
4746
+ 'or read it directly over an administration channel to that machine',
4747
+ ],
4748
+ found: false,
4749
+ },
4750
+ null,
4751
+ 2,
4752
+ );
4753
+ }
4754
+
4755
+ const stateDir = cfg.stateDir ?? null;
4756
+ if (stateDir === null) {
4757
+ return JSON.stringify(
4758
+ {
4759
+ ok: true,
4760
+ scope: 'local',
4761
+ machine_id: selfId,
4762
+ machine_name: self,
4763
+ records: null,
4764
+ reason_code: 'MEMORY_NO_STATE_DIR',
4765
+ detail: 'this plugin has no stateDir configured, so there is nowhere the memory log would be',
4766
+ found: false,
4767
+ },
4768
+ null,
4769
+ 2,
4770
+ );
4771
+ }
4772
+
4773
+ const current = readMemorySync(stateDir, { limit });
4774
+ const previous = args?.include_previous === true ? readMemorySync(stateDir, { limit, previous: true }) : null;
4775
+
4776
+ /**
4777
+ * A small summary before the records themselves.
4778
+ *
4779
+ * The question that sent someone here is usually "have we run this before, and did it work", and a
4780
+ * count answers it faster than reading twenty lines. The counts are of the records *returned*, not
4781
+ * of the whole file, and that is stated -- a summary that silently described a different set than
4782
+ * the one shown is how a reader learns to distrust the summary.
4783
+ */
4784
+ const tally = (records) => {
4785
+ const by = {};
4786
+ for (const r of records ?? []) {
4787
+ const key = r?.status ?? 'unknown';
4788
+ by[key] = (by[key] ?? 0) + 1;
4789
+ }
4790
+ return by;
4791
+ };
4792
+
4793
+ return JSON.stringify(
4794
+ {
4795
+ ok: true,
4796
+ scope: 'local',
4797
+ machine_id: selfId,
4798
+ machine_name: self,
4799
+ state_dir: stateDir,
4800
+ memory_path: current.path,
4801
+ found: current.path !== null && (current.records.length > 0 || previous !== null),
4802
+ count: current.records.length,
4803
+ skipped_lines: current.skipped,
4804
+ truncated: current.truncated,
4805
+ status_tally: tally(current.records),
4806
+ records: current.records,
4807
+ ...(previous === null
4808
+ ? {}
4809
+ : {
4810
+ previous: {
4811
+ path: previous.path,
4812
+ count: previous.records.length,
4813
+ skipped_lines: previous.skipped,
4814
+ status_tally: tally(previous.records),
4815
+ records: previous.records,
4816
+ },
4817
+ }),
4818
+ notes: [
4819
+ current.path === null
4820
+ ? 'no memory has been written here yet: the log is created when a task finishes'
4821
+ : null,
4822
+ current.skipped > 0
4823
+ ? `${current.skipped} line(s) could not be parsed and were skipped; a partially written final line is the expected cause`
4824
+ : null,
4825
+ 'conclusion is null until something records what a result meant; the machine only stores the fact',
4826
+ 'the relay keeps hashes and never this text, so this file is the only durable copy of the output',
4827
+ ].filter((n) => n !== null),
4828
+ },
4829
+ null,
4830
+ 2,
4831
+ );
4832
+ },
4833
+ presentCall: (args) => ({
4834
+ card: 'generic',
4835
+ title: `w2m memory${typeof args?.machine === 'string' ? ` ${args.machine}` : ''}`,
4836
+ kind: 'read',
4837
+ rawInput: {},
4838
+ }),
4839
+ }));
4664
4840
  }
4665
4841
 
4666
4842
  /**
@@ -4735,14 +4911,14 @@ async function startLocalStack(ctx, cfg) {
4735
4911
  *
4736
4912
  * It called `createAgent` with `projectDir` and without `identity`, and passed a
4737
4913
  * `pairingCode` that `createAgent` does not accept. `createAgent` requires `project` and an
4738
- * `identity.machine_id`, so **`autoStartAgent: true` threw on its first call** — the code path
4914
+ * `identity.machine_id`, so **`autoStartAgent: true` threw on its first call** - the code path
4739
4915
  * that was supposed to bring an agent up had never run. That is why the fleet-join wire appeared
4740
4916
  * to be missing: the place it was meant to live could not be reached at all.
4741
4917
  *
4742
4918
  * Two more things were wrong with the shape:
4743
4919
  *
4744
4920
  * * it created a **local relay** unconditionally. For a machine joining a shared fleet that
4745
- * is the opposite of what is wanted — the machine must connect to the relay that already
4921
+ * is the opposite of what is wanted - the machine must connect to the relay that already
4746
4922
  * exists, not mint another one, and with `rabbitUrl` pointing at a remote host it would
4747
4923
  * have tried to bind a relay on that hostname;
4748
4924
  * * nothing paired the agent, so even a started agent could fetch no offers.