@twinsearth/w2m-dsh-plugin 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ 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.26.0] — 2026-10-15
8
+
9
+ **`w2m_approvals`: the approval chain is reachable by an operator, not only by the suite.**
10
+
11
+ The tenth tool. `action: "list"` reads the queue, `approve` and `reject` settle one request by `approval_id`.
12
+ Until now the chain from 0.25.0 was **closed in code and testable end to end while no operator could reach
13
+ it**; this is the tool that opens it.
14
+
15
+ ### Every row says whether *you* may act, and why not when you may not
16
+
17
+ A list filtered to what the caller may answer would be shorter and would **make the fleet look idle at the
18
+ moment it is most blocked.** `listFor` already returns each row with `may_act` and `why_not`; the tool passes
19
+ them through instead of hiding work above the caller's authority.
20
+
21
+ ### `actor_level` is a parameter, and that is deliberate
22
+
23
+ The machine's own `actorAuthorityLevel` says what it may **dispatch as**. An approver's level is a fact about
24
+ the person answering, and a decision made on the dispatcher's level would let a machine approve its own
25
+ escalations. Passing it explicitly is what keeps `APPROVAL_SELF_APPROVAL` meaningful rather than incidental.
26
+
27
+ **Settling without a level is refused** (`W2M_APPROVAL_LEVEL`) rather than defaulted, so a decision is never
28
+ attributed to a level nobody supplied. Listing **without** a level still works and says so: `level_known:
29
+ false`, with a note explaining that the queue is visible but whether any of it is answerable is not known —
30
+ **knowing what is waiting does not require the authority to answer it, and a queue only its supervisors can
31
+ see is a queue that stays full.**
32
+
33
+ ### The note on every settle, because a reader will assume otherwise
34
+
35
+ > *"the request is settled; the dispatch did not run and must be attempted again"*
36
+
37
+ Approval means the next attempt is allowed, not that anything already happened. A tool that settled quietly
38
+ would leave its caller waiting for a result that is never coming.
39
+
40
+ ### Three defects of mine, all caught by the suite or by checking
41
+
42
+ * I called a `json(...)` helper **that does not exist in this file** — every tool returns `JSON.stringify(...)`.
43
+ `node --check` passed, so only reading the surrounding code found it.
44
+ * I referenced `AUTHORITY_MAX` without importing it.
45
+ * I omitted `presentCall`, which `ALLOWED_DEF_KEYS` requires and which the registration-shape test pins. It now
46
+ labels a list as a `read` and a settle as a `write`, because **calling a queue inspection a write would
47
+ misrepresent the common case.**
48
+
49
+ Four tool-count assertions said "exactly nine" and named the nine; they now say ten and name the ten. The
50
+ comment beside one of them already recorded the lesson from a previous version — that a **count** was once the
51
+ wrong property and the **message** was the right one. This time the count *was* the property.
52
+
53
+ **7 new tests, 0 failures.**
54
+
55
+ ## [0.25.0] — 2026-10-15
56
+
57
+ **The chain is closed: dispatch → gate → queue → decision. The plugin builds a queue, and an escalation is a real entry an approver can find.**
58
+
59
+ ### The queue belongs to the plugin, not to the call
60
+
61
+ `apply()` now constructs **one** `createApprovalIndex()` for the plugin's lifetime, lazily. A queue built at
62
+ the call site would be **empty every time**, so a request raised by one dispatch could never be found by the
63
+ decision that answers it — **it would look wired and behave as if it were not.** Same reasoning as the P2P
64
+ runtime beside it: something that must outlive a single call belongs to the plugin.
65
+
66
+ ### Measured, end to end, before it was written down
67
+
68
+ | Step | Result |
69
+ |---|---|
70
+ | an ordinary actor (49) asks to write | `needs_approval`, `enforced: true` |
71
+ | the request is raised | `ok`, id issued, `subjectLevel` **49** |
72
+ | a captain (50) lists the queue | `may_act: true`, `required_level: 50` |
73
+ | a peer (10) lists the same queue | **sees it**, `may_act: false`, `APPROVAL_INSUFFICIENT_AUTHORITY` |
74
+ | the captain settles it | `ok` |
75
+ | after settling | `approved`, **`subjectLevel` still 49** |
76
+ | the requester approves their own request | **`APPROVAL_SELF_APPROVAL`** |
77
+
78
+ The fourth row is the one worth naming: **a peer can see work that is above their authority and is not theirs
79
+ to answer.** A filtered list would make the fleet look idle at the moment it is most blocked.
80
+
81
+ ### A queue that refuses is not a queue that needs approval
82
+
83
+ If `raiseApproval` fails, the dispatch is refused with **`W2M_PERMISSION_APPROVAL_NOT_RAISED`**, a different
84
+ code from `W2M_PERMISSION_NEEDS_APPROVAL`. A caller told "await approval" for a request that was never stored
85
+ would **wait forever for a decision nobody can make.**
86
+
87
+ ### Still true, and stated rather than glossed
88
+
89
+ The dispatch still **does not run** when approval is required — an escalation is a request to be allowed, not
90
+ a permission. There is **no tool yet for an approver to settle a pending request**; `decide()` exists and is
91
+ exercised by the suite, and wiring it to a tool is the next step. So the chain is closed in code and testable
92
+ end to end, while remaining **unreachable by an operator in the running plugin.**
93
+
94
+ ### My probe was wrong before the code was
95
+
96
+ The first end-to-end run threw on `listFor(...).requests.length`. `listFor` returns a **row array**, not an
97
+ object with a `requests` field. **The code was right; my assumption about a shape I had not read was wrong**
98
+ — the fifth time in this project, and the same failure as the parse-prefix mistake early on.
99
+
100
+ **3 new chain tests, 0 failures.**
101
+
7
102
  ## [0.24.0] — 2026-10-15
8
103
 
9
104
  **The escalation reaches the approval index — and the bridge between them is where the defect would have been.**
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 (9 tools) │ │ w2m plugin (9 tools) │
42
+ │ w2m plugin (10 tools) │ │ w2m plugin (10 tools) │
43
43
  │ │ │ │ │ │
44
44
  │ Localside agent │ │ Localside agent │
45
45
  │ · three anchors │ │ · three anchors │
@@ -293,15 +293,16 @@ 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, nine tools appear, and
296
+ > That is a silent failure by construction — the install looks right, ten 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 nine tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
304
- `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`.
303
+ Restart DSH. You should see ten tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
304
+ `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`,
305
+ `w2m_approvals`.
305
306
 
306
307
  > `dsh` may not be on your `PATH`. On a packaged install it lives under
307
308
  > `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
@@ -584,7 +585,7 @@ a product.
584
585
  |---|---|---|
585
586
  | 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
587
  | 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 | nine tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
588
+ | tools | 89 | ten tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
588
589
  | 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
590
  | claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
590
591
  | fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
@@ -712,7 +713,7 @@ the plugin:
712
713
 
713
714
  ```bash
714
715
  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 9 tools
716
+ node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 10 tools
716
717
  ```
717
718
 
718
719
  **Not verified:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
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",
@@ -89,7 +89,7 @@
89
89
  */
90
90
 
91
91
  import { execFile } from 'node:child_process';
92
- import { createHash } from 'node:crypto';
92
+ import { createHash, randomUUID } 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';
@@ -125,8 +125,11 @@ import { mintPunchId, punchSession } from '../agent/p2p.mjs';
125
125
  // v0.22.0: the permission gate. Imported rather than reimplemented for the same reason as everything else
126
126
  // here -- a second copy of the escalation rule is a second chance to disagree with the first about how far
127
127
  // below a requirement an actor may still ask.
128
- import { gateDispatch, approvalRequestFor } from '../agent/permission.mjs';
129
- import { isAuthorityLevel } from '../agent/authority.mjs';
128
+ import { gateDispatch, approvalRequestFor, raiseApproval } from '../agent/permission.mjs';
129
+ // v0.24.0: the queue an escalation is raised into. Imported from `src/`, never from `scripts/` -- a
130
+ // `src/` module reaching above itself breaks the published package (see CHANGELOG 0.23.0).
131
+ import { createApprovalIndex } from '../agent/approval-index.mjs';
132
+ import { AUTHORITY_MAX, isAuthorityLevel } from '../agent/authority.mjs';
130
133
  // v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
131
134
  // rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
132
135
  // implementations of a handshake are two chances to disagree about it.
@@ -190,7 +193,7 @@ const MAX_STAGES = 16;
190
193
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
191
194
  * overrides it at runtime for tests and packagers.
192
195
  */
193
- const PLUGIN_VERSION = '0.24.0';
196
+ const PLUGIN_VERSION = '0.26.0';
194
197
 
195
198
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
196
199
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3202,6 +3205,29 @@ export async function apply(ctx, config = {}) {
3202
3205
  return { machine_id: device.machine_id, machine_name: device.machine_name, machineName: cfg.machineName };
3203
3206
  });
3204
3207
 
3208
+ /**
3209
+ * v0.24.0: the approval queue, one per plugin lifetime.
3210
+ *
3211
+ * ## Why it lives here and not inside the dispatch
3212
+ *
3213
+ * A queue constructed at the call site would be **empty every time**, so a request raised by one dispatch
3214
+ * could never be found by the decision that answers it — the queue would look wired and behave as if it were
3215
+ * not. This is the same reasoning as `p2p` above: something that has to outlive a single call belongs to the
3216
+ * plugin, not to the call.
3217
+ *
3218
+ * Lazy, because a machine that never trips the permission gate should not pay for a queue, and built through
3219
+ * the effect system so a decision still pending at unload does not keep the plugin alive.
3220
+ */
3221
+ let approvals = null;
3222
+ const approvalQueue = () => {
3223
+ if (approvals === null) {
3224
+ approvals = createApprovalIndex({
3225
+ audit: (event, detail) => ctx.logger?.debug?.(`w2m: ${event} ${JSON.stringify(detail ?? {})}`),
3226
+ });
3227
+ }
3228
+ return approvals;
3229
+ };
3230
+
3205
3231
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3206
3232
  // 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
3207
3233
  // composition and does not need a warning on every headless run.
@@ -3725,16 +3751,33 @@ export async function apply(ctx, config = {}) {
3725
3751
  );
3726
3752
  }
3727
3753
  if (gate.decision.verdict === 'needs_approval') {
3754
+ // v0.24.0: the request is QUEUED now, not merely refused with a description of a queue. The
3755
+ // dispatch still does not run -- an escalation is a request to be allowed, not a permission --
3756
+ // but the request is a real entry an approver can find and settle, and its id comes back so the
3757
+ // caller can name what it is waiting on.
3728
3758
  const request = approvalRequestFor(gate.decision, {
3729
3759
  taskId: null,
3730
3760
  machineId: null,
3731
3761
  requestedBy: cfg.machineName,
3732
3762
  });
3763
+ const approvalId = randomUUID();
3764
+ const raised = raiseApproval(approvalQueue(), request, { approvalId, toolName: 'w2m_run' });
3765
+ if (!raised.ok) {
3766
+ // The queue refused it, which is a different failure from needing approval and must not be
3767
+ // reported as one: a caller told "await approval" for a request that was never stored would wait
3768
+ // forever for a decision nobody can make.
3769
+ throw new W2MError(
3770
+ 'W2M_PERMISSION_APPROVAL_NOT_RAISED',
3771
+ 'W2M_PERMISSION_APPROVAL_NOT_RAISED: ' + raised.reason_code + ': ' + raised.detail,
3772
+ { hint: 'the approval request could not be queued, so this dispatch is refused rather than left waiting', request },
3773
+ );
3774
+ }
3733
3775
  throw new W2MError(
3734
3776
  'W2M_PERMISSION_NEEDS_APPROVAL',
3735
3777
  'W2M_PERMISSION_NEEDS_APPROVAL: ' + gate.decision.detail,
3736
3778
  {
3737
- hint: 'this dispatch needs an approver; it is refused rather than queued because no approval queue is wired to this call yet',
3779
+ hint: 'this dispatch is queued awaiting an approver; approve or reject it with w2m_approvals, then dispatch again',
3780
+ approval_id: raised.approval_id,
3738
3781
  request,
3739
3782
  },
3740
3783
  );
@@ -5045,6 +5088,131 @@ export async function apply(ctx, config = {}) {
5045
5088
  rawInput: {},
5046
5089
  }),
5047
5090
  }));
5091
+
5092
+ /**
5093
+ * v0.25.0: the tool that makes the approval chain reachable by an operator.
5094
+ *
5095
+ * ## Why it reports refusals instead of hiding them
5096
+ *
5097
+ * Every row carries `may_act` and, when false, `why_not`. A list filtered down to what the caller may
5098
+ * answer would be shorter and **would make the fleet look idle at the moment it is most blocked** — the
5099
+ * work above their authority is exactly the work they most need to know exists.
5100
+ *
5101
+ * ## Why `actor_level` is a parameter and not read from config
5102
+ *
5103
+ * This machine's own `actorAuthorityLevel` says what it may *dispatch as*. An approver's level is a fact
5104
+ * about the person answering, and a decision made on the dispatcher's level would let a machine approve its
5105
+ * own escalations — which the index already refuses by name (`APPROVAL_SELF_APPROVAL`) when the ids match.
5106
+ * Making it explicit keeps that refusal meaningful rather than incidental.
5107
+ */
5108
+ ctx.tools.register(defineTool({
5109
+ name: 'w2m_approvals',
5110
+ description:
5111
+ 'List and settle the dispatches that are waiting for an approver. A dispatch whose actor level is ' +
5112
+ 'exactly one band below what it needs is QUEUED rather than refused, and this is where that queue is ' +
5113
+ 'read and answered. Every row reports whether YOU may act on it and why not if you may not, so work ' +
5114
+ 'above your authority is visible rather than filtered away. Settling a request does not run the ' +
5115
+ 'dispatch: approval means the next attempt is allowed, not that anything already happened.',
5116
+ parameters: {
5117
+ action: {
5118
+ type: 'string',
5119
+ enum: ['list', 'approve', 'reject'],
5120
+ description: 'list (default) shows the queue; approve and reject settle one request by approval_id.',
5121
+ default: 'list',
5122
+ },
5123
+ approval_id: {
5124
+ type: 'string',
5125
+ description: 'Which request to settle. Required for approve and reject.',
5126
+ },
5127
+ actor_id: {
5128
+ type: 'string',
5129
+ description: 'Who is answering. Defaults to this machine\'s name; it is recorded on the decision, and it is what makes a self-approval refusable.',
5130
+ },
5131
+ actor_level: {
5132
+ type: 'number',
5133
+ description: 'The authority level of the person answering, 0..100. Required to approve or reject: an approver with no level cannot be placed relative to the request, and the index refuses rather than guessing one.',
5134
+ },
5135
+ include_settled: {
5136
+ type: 'boolean',
5137
+ description: 'Also show recently settled requests. They are kept briefly as receipts, so a supervisor returning to a busy queue can tell "already dealt with" from "never seen".',
5138
+ default: false,
5139
+ },
5140
+ },
5141
+ output: jsonOutput,
5142
+ presentCall: (args) => ({
5143
+ card: 'generic',
5144
+ title: `w2m approvals ${typeof args?.action === 'string' ? args.action : 'list'}`,
5145
+ // Only `approve` and `reject` change anything; a list is a read. Labelling the whole tool 'write' would
5146
+ // make the common case -- looking at the queue -- read as an action.
5147
+ kind: args?.action === 'approve' || args?.action === 'reject' ? 'write' : 'read',
5148
+ rawInput: {},
5149
+ }),
5150
+ async execute(args, exec) {
5151
+ throwIfAborted(exec?.signal);
5152
+ const action = typeof args?.action === 'string' ? args.action : 'list';
5153
+ const queue = approvalQueue();
5154
+
5155
+ if (action === 'approve' || action === 'reject') {
5156
+ const approvalId = typeof args?.approval_id === 'string' ? args.approval_id.trim() : '';
5157
+ if (approvalId === '') {
5158
+ throw new W2MError('W2M_APPROVAL_ID', 'W2M_APPROVAL_ID: settling a request needs `approval_id`', {
5159
+ hint: 'call w2m_approvals with action="list" to see the pending ids',
5160
+ });
5161
+ }
5162
+ if (!isAuthorityLevel(args?.actor_level)) {
5163
+ // Refused rather than defaulted, and refused HERE rather than left to the index, so the message can
5164
+ // say what to do about it. A decision attributed to a level nobody supplied is a decision nobody
5165
+ // can audit.
5166
+ throw new W2MError(
5167
+ 'W2M_APPROVAL_LEVEL',
5168
+ 'W2M_APPROVAL_LEVEL: `actor_level` must be an integer 0..' + AUTHORITY_MAX + ' to settle a request',
5169
+ { hint: 'an approver with no level cannot be placed relative to the request it is answering' },
5170
+ );
5171
+ }
5172
+ const actor = { id: typeof args?.actor_id === 'string' && args.actor_id.trim() !== '' ? args.actor_id.trim() : cfg.machineName, level: args.actor_level };
5173
+ const outcome = action === 'approve' ? 'approved' : 'rejected';
5174
+ const result = queue.decide(approvalId, actor, outcome);
5175
+ return JSON.stringify({
5176
+ ok: result.ok === true,
5177
+ action,
5178
+ approval_id: approvalId,
5179
+ settled: result.ok === true ? outcome : null,
5180
+ reason_code: result.reason_code ?? null,
5181
+ detail: result.detail ?? null,
5182
+ acted_by: actor,
5183
+ // Said every time, because a reader who assumes approval ran the dispatch will wait for a result
5184
+ // that is never coming.
5185
+ note: result.ok === true
5186
+ ? 'the request is settled; the dispatch did not run and must be attempted again'
5187
+ : 'the decision was refused; the request is unchanged and still waiting',
5188
+ });
5189
+ }
5190
+
5191
+ const actor = {
5192
+ id: typeof args?.actor_id === 'string' && args.actor_id.trim() !== '' ? args.actor_id.trim() : cfg.machineName,
5193
+ level: isAuthorityLevel(args?.actor_level) ? args.actor_level : null,
5194
+ };
5195
+ // An actor with no level still gets the list: knowing what is waiting does not require the authority to
5196
+ // answer it, and a queue only its supervisors can see is a queue that stays full.
5197
+ const rows = queue.listFor(
5198
+ actor.level === null ? { id: actor.id, level: 0 } : actor,
5199
+ { includeSettled: args?.include_settled === true },
5200
+ );
5201
+ const summary = queue.summary(actor.level === null ? { id: actor.id, level: 0 } : actor);
5202
+ return JSON.stringify({
5203
+ ok: true,
5204
+ action: 'list',
5205
+ as: actor,
5206
+ level_known: actor.level !== null,
5207
+ summary,
5208
+ count: rows.length,
5209
+ rows,
5210
+ note: actor.level === null
5211
+ ? 'no actor_level was supplied, so may_act is reported as if the level were 0: the queue is visible, but whether you may answer any of it is not known'
5212
+ : 'may_act reflects the actor_level supplied',
5213
+ });
5214
+ },
5215
+ }));
5048
5216
  }
5049
5217
 
5050
5218
  /**