phaedo-mcp 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
package/consult.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
18
18
  deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
19
19
  authoredDelegationSignals,
20
+ RESOLVER_REVISION,
20
21
  } from './consult-core.js';
21
22
 
22
23
  // Re-export the core surface so existing importers (server.js, tests,
@@ -102,9 +103,32 @@ export function consultOptsFromEnv(env = process.env) {
102
103
  apiKey: env.PHAEDO_CONSULT_API_KEY || null, // for an OpenAI-compatible endpoint
103
104
  timeoutMs: Number(env.PHAEDO_CONSULT_TIMEOUT_MS) || 15000,
104
105
  policies: null, // a loaded policy doc; the server fills this from the policy file
106
+ // §10.5 `agents`: this server's OWN paired-client id, filled by the server from
107
+ // `pairing.json` (see readServingClientId). Null = unpaired, which makes every
108
+ // `agents`-scoped relaxing rule inert. Never sourced from the environment or the
109
+ // caller — it must not be spoofable by whatever launched the process.
110
+ clientId: null,
105
111
  };
106
112
  }
107
113
 
114
+ // Which engine answered a response this module returned: the opt-in model judge, or the
115
+ // deterministic rules. A WeakMap rather than a field because the §10.3 response shape is
116
+ // closed (additionalProperties:false) and this is local provenance, never for the agent.
117
+ const JUDGED_BY = new WeakMap();
118
+
119
+ /**
120
+ * Append the `resolverRevision` provenance driver to a consult's drivers: the rules'
121
+ * RESOLVER_REVISION, or `judge:<model>` when the model judge answered. Only when there are
122
+ * drivers already — the receipt schema documents drivers as absent for deterministic
123
+ * pre-flights and abstains, which carry no inferred prediction to attribute.
124
+ * Pass the response EXACTLY as resolveConsultation returned it (before any copy).
125
+ */
126
+ export function withRevision(drivers, response) {
127
+ if (!Array.isArray(drivers) || !drivers.length) return Array.isArray(drivers) ? drivers : [];
128
+ const judge = response && typeof response === 'object' ? JUDGED_BY.get(response) : undefined;
129
+ return [...drivers, { cue: 'resolverRevision', value: judge ? `judge:${judge}` : RESOLVER_REVISION, confidence: null, basis: null }];
130
+ }
131
+
108
132
  // Public async entry: model judge when configured, else the deterministic core.
109
133
  // voice_draft always takes the (redirect) core path. Authored policies (§10.5)
110
134
  // override either result, so a standing guardrail always wins. Never throws except
@@ -125,7 +149,11 @@ export async function resolveConsultation(vd, request = {}, opts = {}) {
125
149
  try {
126
150
  const judged = await modelJudge(vd, n, opts);
127
151
  // Authored guardrails override the model judge too (applied last so they win).
128
- return applyPolicies(judged, n, loadPolicies(vd, opts));
152
+ // Same §10.5 context as the deterministic path — including the serving
153
+ // `client_id`, so `agents` scoping is not bypassed by configuring a model.
154
+ const out = applyPolicies(judged, n, loadPolicies(vd, opts), { now: opts.now, clientId: opts.clientId ?? null });
155
+ if (out && typeof out === 'object') JUDGED_BY.set(out, String(opts.model));
156
+ return out;
129
157
  } catch (err) {
130
158
  process.stderr.write?.(`[phaedo-consult] model judge fell back to rules: ${err.message}\n`);
131
159
  }
@@ -0,0 +1,95 @@
1
+ // Delegation-promotion transport (MCP → relay → extension).
2
+ //
3
+ // Deposits act-as-me delegation SUGGESTIONS (built by consult-core.js
4
+ // buildDelegationPromotions from the consult override history) into the relay's
5
+ // `agent_to_ext` mailbox slot, encrypted under fp_sync_key.
6
+ //
7
+ // ⚠️ KEY DEPENDENCY (not merely historical — see
8
+ // docs/roadmap/paired-client-identity-2026-07-22.md). fp_sync_key is derived from
9
+ // pair_key, and the EXTENSION drains this lane with its OWN single fp_sync_key — no
10
+ // multi-client key loop, unlike the phone's drains. So this deposit is readable only
11
+ // when the MCP and the extension share a pair_key (the original "Option B": the
12
+ // extension's pairing exported to mcp/pairing.json). An MCP paired independently via
13
+ // `pair.js` derives a DIFFERENT key, and the extension logs "likely key mismatch" and
14
+ // drops the suggestion silently. Independent pairing is what makes the MCP a distinct
15
+ // principal for §10.5 `agents` scoping, so these two properties are in tension until
16
+ // the extension's drain gains the phone's try-each-verified-client loop.
17
+ //
18
+ // RESOLVED BY THE COURIER (verified 2026-09-24): the extension still cannot open an
19
+ // independently-paired MCP's deposit, but it no longer has to. The PHONE drains this lane
20
+ // with every verified client's key (mobile/src/lib/fpSync.ts drainAgentSuggestions) and
21
+ // forwards the suggestions inside its to_ext deposit, which since spec v0.1.70 reaches EVERY
22
+ // paired browser under that browser's own key. Independent pairing is the supported setup.
23
+ //
24
+ // The extension union-merges
25
+ // the suggestions (sync-merge.js mergeAgentSuggestions) into suggested_rules, where the
26
+ // existing popup review card surfaces them for endorsement.
27
+ //
28
+ // Trust posture: the MCP only ever proposes SUGGESTIONS — never an authored rule. The
29
+ // subject still endorses (the authored-only invariant holds). Best-effort and isolated:
30
+ // a deposit failure never affects the consultation that triggered it. The relay only
31
+ // ever holds ciphertext (the agent_to_ext slot is separate from the phone's to_ext, so
32
+ // a deposit can neither clobber nor be clobbered by the phone's fingerprint sync).
33
+
34
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
35
+
36
+ const { Kdf, Envelope } = Phaedo;
37
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
38
+
39
+ export function relayBaseFromEnv(env = process.env) {
40
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
41
+ }
42
+
43
+ // Wrap delegation suggestions into the same envelope shape the extension's fp-mailbox
44
+ // drain expects: ciphertext over `{ data: { suggested_rules: [...] } }`. Returns the
45
+ // deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
46
+ export async function buildDelegationDeposit(pairKeyB64u, suggestions, { now = () => new Date().toISOString() } = {}) {
47
+ const pairKey = b64uDec(pairKeyB64u);
48
+ let fpSyncKey;
49
+ try {
50
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
51
+ } finally {
52
+ pairKey.fill(0);
53
+ }
54
+ try {
55
+ const inner = { kind: 'delegation_suggestions', data: { suggested_rules: suggestions } };
56
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
57
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'delegation_suggestions', created_at: now() });
58
+ plaintext.fill(0);
59
+ return { envelope };
60
+ } finally {
61
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
62
+ }
63
+ }
64
+
65
+ // Deposit the suggestions to the relay's agent_to_ext slot. Best-effort: returns
66
+ // { deposited, ... } and never throws (a transport failure must not break the consult).
67
+ // `deps.fetch` is injectable for tests.
68
+ export async function depositDelegationSuggestions(pairing, suggestions, deps = {}) {
69
+ const fetchFn = deps.fetch || globalThis.fetch;
70
+ const list = Array.isArray(suggestions) ? suggestions : [];
71
+ // Precise reasons so the caller can surface WHY a deposit didn't happen (a missing
72
+ // relay_device_id means a local-only pairing — the deposit silently no-ops otherwise).
73
+ if (!list.length) return { deposited: false, reason: 'no-suggestions' };
74
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
75
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
76
+ try {
77
+ const body = await buildDelegationDeposit(pairing.pair_key, list, deps);
78
+ const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_ext`;
79
+ // Per-device auth header (relay/auth.js). Without it the relay 401s. A pairing.json
80
+ // from an older mobile build that predates the auth layer won't have relay_device_secret
81
+ // → no header → 401 → the deposit no-ops, which is the correct failure mode (user re-pairs).
82
+ const authHeader = pairing.relay_device_secret
83
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
84
+ : {};
85
+ const res = await fetchFn(base, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', ...authHeader },
88
+ body: JSON.stringify(body),
89
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
90
+ });
91
+ return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
92
+ } catch (e) {
93
+ return { deposited: false, reason: String(e && e.message || e) };
94
+ }
95
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "0.1",
3
+ "_comment": "Subject-configurable PUSH policy for phaedo_escalate (docs/roadmap/escalation-and-push.md). Controls which consult signals WAKE your phone for a live decision. `escalate` ALWAYS pushes (it means a human is needed) and is not configurable here. Only `clarify` is tunable: mode is \"always\" | \"consequential\" (the default: irreversible OR high-magnitude actions only) | \"never\". A per-domain override (by_domain, substring match, case-insensitive) beats the default. Portable: copy into the vault as phaedo_fingerprint.escalation_policy to sync across devices, or point PHAEDO_ESCALATION_POLICY at this file to try it. The file overrides the vault policy.",
4
+ "clarify": {
5
+ "default": "consequential",
6
+ "by_domain": {
7
+ "financial": "always",
8
+ "legal": "always",
9
+ "writing": "never",
10
+ "internal note": "never"
11
+ }
12
+ }
13
+ }
package/escalation.js ADDED
@@ -0,0 +1,383 @@
1
+ // Real-time escalation client (MCP → relay → subject's device → back).
2
+ //
3
+ // The delivery channel the act-as-me endgame needs once agents act AUTONOMOUSLY for
4
+ // the subject (docs/roadmap/escalation-and-push.md). When a consult returns a
5
+ // process-BLOCKING signal — `escalate` (or `clarify`) — and the agent is running
6
+ // unattended, there is no one at the agent's conversation to answer. This routes the
7
+ // decision to the subject's phone in real time, waits within a bounded window, and
8
+ // returns approve / deny / modify so the agent can resume — or applies the SAFE
9
+ // DEFAULT when the window lapses (an `escalate` means "hold for a human", so the
10
+ // no-response default is do-not-proceed, never a guess).
11
+ //
12
+ // §10.4 boundary on the wire: the escalation payload carries the ACTION under
13
+ // question (the agent's own descriptor) + the consult's `signal`/`rationale_hint` —
14
+ // NEVER fingerprint layer content. And even that is sealed under fp_sync_key (the
15
+ // same HKDF(pair_key) key the delegation deposit uses), so the relay stays
16
+ // zero-knowledge: it forwards ciphertext and wakes the device, nothing more.
17
+ //
18
+ // Honesty rail (roadmap Q7): a real-time approve/deny IS an outcome — the strongest
19
+ // possible act-as-me signal. The orchestrator returns a `record_outcome` mapping
20
+ // (approve→approved, deny→rejected, modify→modified) so the SAME §10.6 receipt +
21
+ // override-learning loop picks it up automatically. A TIMEOUT is "no answer", not an
22
+ // override — it maps to `unknown` and teaches nothing.
23
+
24
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
25
+ import { normalizeActionBinding } from './action-binding.js';
26
+
27
+ const { Kdf, Envelope } = Phaedo;
28
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
29
+
30
+ export function relayBaseFromEnv(env = process.env) {
31
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
32
+ }
33
+
34
+ // The Authorization header for relay HTTP routes (relay/auth.js per-device bearer).
35
+ // Without this, deposit/poll return 401. A pairing without a relay_device_secret (e.g.
36
+ // from an older mobile build that predates the auth layer) gets no header — relay 401s,
37
+ // which surfaces as deposit-failed-401 from the orchestrator.
38
+ function relayAuthHeader(pairing) {
39
+ if (!pairing || !pairing.relay_device_id || !pairing.relay_device_secret) return {};
40
+ return { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` };
41
+ }
42
+
43
+ // Which consult signals are BLOCKING enough to WAKE the subject (roadmap Q1). The two
44
+ // blocking signals are not equal:
45
+ // - `escalate` = "hold for a live human go/no-go on a consequential call." Blocking by
46
+ // definition → ALWAYS push.
47
+ // - `clarify` = "I need more info before acting." This is the agent's default reflex
48
+ // when uncertain, so it fires often, including on low-stakes calls. Waking the phone
49
+ // for every clarify would cry wolf — so push a clarify ONLY when the action is also
50
+ // CONSEQUENTIAL (irreversible OR high magnitude). Below that bar an autonomous agent
51
+ // should resolve the clarify itself (ask in-flow / state a reasonable assumption) and
52
+ // it surfaces in the deferred review queue — no interruption.
53
+ // Unknown stakes (no descriptor) reads as low-stakes for clarify: the agent can always
54
+ // raise an explicit `escalate` when it really needs the subject. Ultimately this whole
55
+ // policy is subject-configurable via a standing rule; this is the sensible default.
56
+ export function isConsequential(actionDescriptor = {}) {
57
+ const ad = actionDescriptor || {};
58
+ return ad.reversible === false || ad.magnitude === 'high';
59
+ }
60
+
61
+ // ── Subject-configurable push policy (the standing knob) ───────────────────────
62
+ // The stakes-gated default above is just that — a default. A subject can override how
63
+ // `clarify` is pushed, globally and per-domain, via an escalation policy that rides the
64
+ // vault (portable: `phaedo_fingerprint.escalation_policy`, syncs across devices) and/or a
65
+ // local file (`PHAEDO_ESCALATION_POLICY`, for power users / testing) — the same dual home
66
+ // as consult policies (`consult-policy.js`). Shape:
67
+ // { "clarify": { "default": "always" | "consequential" | "never",
68
+ // "by_domain": { "financial": "always", "writing": "never" } } }
69
+ // `escalate` is deliberately NOT configurable: it means "a human is needed", so suppressing
70
+ // the wake would strand a blocking decision — it always pushes. Only `clarify` is tunable.
71
+ const CLARIFY_MODES = new Set(['always', 'consequential', 'never']);
72
+
73
+ // Merge policy sources (later wins; `by_domain` deep-merged, `default` shallow-overridden).
74
+ // Defensive: ignores malformed entries and unknown modes. Never throws.
75
+ export function mergeEscalationPolicy(...sources) {
76
+ const clarify = { default: undefined, by_domain: {} };
77
+ for (const s of sources) {
78
+ const c = s && typeof s === 'object' ? s.clarify : null;
79
+ if (!c || typeof c !== 'object') continue;
80
+ if (typeof c.default === 'string' && CLARIFY_MODES.has(c.default.toLowerCase())) clarify.default = c.default.toLowerCase();
81
+ if (c.by_domain && typeof c.by_domain === 'object') {
82
+ for (const [k, v] of Object.entries(c.by_domain)) {
83
+ if (typeof v === 'string' && CLARIFY_MODES.has(v.toLowerCase())) clarify.by_domain[String(k).toLowerCase()] = v.toLowerCase();
84
+ }
85
+ }
86
+ }
87
+ return { clarify };
88
+ }
89
+
90
+ // Resolve the effective policy for a consult: the vault-embedded policy (portable) merged
91
+ // under the local-file override (`opts.escalationPolicy`). Pure; defensive.
92
+ export function loadEscalationPolicy(vd, opts = {}) {
93
+ return mergeEscalationPolicy(vd?.phaedo_fingerprint?.escalation_policy, opts.escalationPolicy);
94
+ }
95
+
96
+ // Does a `by_domain` KEY govern this action domain? EXACT match, or a dot-hierarchy
97
+ // PARENT match (`finance` governs `finance.crypto_trading`).
98
+ //
99
+ // This deliberately replaces the old bidirectional substring test
100
+ // (`domain.includes(k) || k.includes(domain)`), which was both wrong and unsafe:
101
+ // - `legal` spuriously governed `paralegal`, and `care` governed `healthcare` —
102
+ // accidental substrings, not subdomains. So a subject who set `legal: never` to
103
+ // silence legal noise ALSO silenced every paralegal action, and a suppressed
104
+ // clarify never reaches the phone: the failure is invisible by construction.
105
+ // - the reverse direction (`k.includes(domain)`) let a narrow key govern a broad
106
+ // action — a `finance.crypto_trading` rule firing on a bare `finance` action.
107
+ // A real subdomain rides a `.` boundary; a coincidental letter overlap does not.
108
+ // Parent→child only (a `finance` rule covers its subdomains; a crypto rule does NOT
109
+ // reach up to govern general finance). Anything unmatched falls to `default`, which
110
+ // the subject controls — never to a wrong, more-specific rule.
111
+ function domainRuleApplies(key, domain) {
112
+ return domain === key || domain.startsWith(key + '.');
113
+ }
114
+
115
+ // The clarify push mode for an action under a policy: the MOST SPECIFIC per-domain
116
+ // override wins over the default, which falls back to the built-in 'consequential'.
117
+ //
118
+ // "Most specific" = the longest applicable key, so `finance.crypto_trading` beats a
119
+ // bare `finance` for a crypto action. This is also what makes the result
120
+ // DETERMINISTIC: the old first-match-wins loop returned different modes for the same
121
+ // action depending on JSON key order, so a subject's specific rule could lose to a
122
+ // general one purely by where it sat in the object.
123
+ export function resolveClarifyMode(actionDescriptor = {}, policy = null) {
124
+ const c = policy && policy.clarify;
125
+ if (!c) return 'consequential';
126
+ const domain = String(actionDescriptor?.domain || '').toLowerCase();
127
+ if (domain && c.by_domain) {
128
+ let best = null;
129
+ for (const [k, mode] of Object.entries(c.by_domain)) {
130
+ if (!CLARIFY_MODES.has(mode)) continue;
131
+ if (domainRuleApplies(k, domain) && (best === null || k.length > best.k.length)) best = { k, mode };
132
+ }
133
+ if (best) return best.mode;
134
+ }
135
+ return CLARIFY_MODES.has(c.default) ? c.default : 'consequential';
136
+ }
137
+
138
+ // Does this signal WAKE the subject? `escalate` always; `clarify` per the policy
139
+ // (`always` | `never` | `consequential` = irreversible/high-magnitude). Backward
140
+ // compatible: with no policy, clarify falls back to the stakes-gated default.
141
+ export function shouldEscalate(signal, actionDescriptor = {}, policy = null) {
142
+ const s = String(signal);
143
+ if (s === 'escalate') return true;
144
+ if (s === 'clarify') {
145
+ const mode = resolveClarifyMode(actionDescriptor, policy);
146
+ return mode === 'always' ? true : mode === 'never' ? false : isConsequential(actionDescriptor);
147
+ }
148
+ return false;
149
+ }
150
+
151
+ // The decision vocabulary the phone returns, mapped to the §10.6 receipt user_action.
152
+ const DECISION_OUTCOME = { approve: 'approved', deny: 'rejected', modify: 'modified' };
153
+
154
+ // Build the §10.4-safe escalation payload from the consult request + its response.
155
+ // PURE. Pulls ONLY the agent's own action descriptor + the constrained consult
156
+ // signal/rationale — no vault data is in scope here at all. Truncates free text so a
157
+ // runaway descriptor can't bloat the (encrypted) wire payload.
158
+ export function buildEscalationPayload(consultRequest = {}, consultResponse = {}) {
159
+ const ad = (consultRequest.action_descriptor && typeof consultRequest.action_descriptor === 'object') ? consultRequest.action_descriptor : {};
160
+ const clip = (s, n) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().slice(0, n) : undefined);
161
+ // The §10.2 binding, when the agent sent one. `summary` above is the agent's
162
+ // PROSE — its account of what it is about to do. This is the call itself. The
163
+ // subject approving an escalation should see both, because the whole class of
164
+ // approval-gate bypasses works by making the two differ.
165
+ let bnd = null;
166
+ try { bnd = normalizeActionBinding(consultRequest.action_binding); } catch { bnd = null; }
167
+ return {
168
+ consultation_type: typeof consultRequest.consultation_type === 'string' ? consultRequest.consultation_type : undefined,
169
+ agent_id: clip(consultRequest.agent_id, 120),
170
+ action: {
171
+ domain: clip(ad.domain, 80),
172
+ summary: clip(ad.summary, 280),
173
+ magnitude: ['low', 'medium', 'high'].includes(ad.magnitude) ? ad.magnitude : undefined,
174
+ reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
175
+ amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
176
+ ...(bnd ? {
177
+ tool_name: bnd.tool_name,
178
+ arguments_hash: bnd.arguments_hash,
179
+ ...(bnd.arguments_preview ? { arguments_preview: bnd.arguments_preview } : {}),
180
+ } : {}),
181
+ },
182
+ signal: clip(consultResponse.signal, 40),
183
+ rationale_hint: clip(consultResponse.rationale_hint, 280),
184
+ };
185
+ }
186
+
187
+ // Seal the payload under fp_sync_key into the deposit body { request_id, envelope,
188
+ // window_ms }. PURE crypto (no network) so the round-trip is testable in isolation,
189
+ // mirroring delegation-sync.js buildDelegationDeposit.
190
+ export async function buildEscalationDeposit(pairKeyB64u, requestId, payload, { windowMs, now = () => new Date().toISOString() } = {}) {
191
+ const pairKey = b64uDec(pairKeyB64u);
192
+ let fpSyncKey;
193
+ try {
194
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
195
+ } finally {
196
+ pairKey.fill(0);
197
+ }
198
+ try {
199
+ const inner = { kind: 'escalation_request', request_id: requestId, data: payload };
200
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
201
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'escalation_request', created_at: now() });
202
+ plaintext.fill(0);
203
+ return { request_id: requestId, envelope, ...(windowMs ? { window_ms: windowMs } : {}) };
204
+ } finally {
205
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
206
+ }
207
+ }
208
+
209
+ // Decrypt the subject's decision (the phone's response body { envelope }) under
210
+ // fp_sync_key. Returns the inner { decision, note?, modified? } or null on any
211
+ // failure (a malformed/forged response must never crash the agent's resume path).
212
+ export async function unwrapEscalationResponse(pairKeyB64u, body) {
213
+ if (!body || !body.envelope) return null;
214
+ const pairKey = b64uDec(pairKeyB64u);
215
+ let fpSyncKey;
216
+ try {
217
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
218
+ } catch { pairKey.fill(0); return null; }
219
+ pairKey.fill(0);
220
+ try {
221
+ const plaintext = await Envelope.unwrap(body.envelope, fpSyncKey);
222
+ const inner = JSON.parse(new TextDecoder().decode(plaintext));
223
+ if (inner && inner.kind === 'escalation_response' && inner.data) return inner.data;
224
+ return null;
225
+ } catch {
226
+ return null;
227
+ } finally {
228
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
229
+ }
230
+ }
231
+
232
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
233
+ function genRequestId() {
234
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
235
+ return `esc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
236
+ }
237
+
238
+ // ── Non-blocking deposit + single-poll (for the MCP tools) ─────────────────────
239
+ // A multi-minute BLOCKING tool call is hostile to MCP clients (many time out), so the
240
+ // agent-facing tools use a two-call shape: phaedo_escalate deposits + wakes the device
241
+ // and returns immediately; phaedo_escalation_status polls once. The agent decides when
242
+ // to stop waiting; the safe default once it gives up is HOLD (do-not-proceed).
243
+
244
+ // Deposit the (encrypted) decision request and wake the device. Returns
245
+ // { deposited, request_id, window_ms, expires_at } or { deposited:false, reason }.
246
+ // Never throws.
247
+ export async function depositEscalation(pairing, consultRequest, consultResponse, deps = {}) {
248
+ const fetchFn = deps.fetch || globalThis.fetch;
249
+ const signal = consultResponse && consultResponse.signal;
250
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired', signal };
251
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id', signal };
252
+ const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
253
+ const relayBase = deps.relayBase || relayBaseFromEnv();
254
+ const requestId = deps.requestId || genRequestId();
255
+ try {
256
+ const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
257
+ const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
260
+ body: JSON.stringify(body),
261
+ signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
262
+ });
263
+ if (!res || !res.ok) return { deposited: false, reason: `deposit-failed-${res && res.status}`, request_id: requestId, signal };
264
+ let info = {};
265
+ try { info = await res.json(); } catch { /* body optional */ }
266
+ return { deposited: true, request_id: requestId, window_ms: windowMs, expires_at: info.expires_at, woke: info.woke, signal };
267
+ } catch (e) {
268
+ return { deposited: false, reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId, signal };
269
+ }
270
+ }
271
+
272
+ // Poll ONCE for a decision. Returns { resolved:true, decision, proceed, outcome, note?,
273
+ // modified? } when answered, { resolved:false, status:'pending' } while waiting, or
274
+ // { resolved:false, status:'expired' } once the agent's deadline has passed (the caller
275
+ // then applies the safe-default hold). Never throws.
276
+ export async function pollEscalationOnce(pairing, requestId, deps = {}) {
277
+ const fetchFn = deps.fetch || globalThis.fetch;
278
+ if (!pairing || !pairing.pair_key || !requestId) return { resolved: false, status: 'expired', reason: 'bad-args' };
279
+ if (deps.expiresAt && (deps.now ? deps.now() : Date.now()) > deps.expiresAt) return { resolved: false, status: 'expired' };
280
+ const relayBase = deps.relayBase || relayBaseFromEnv();
281
+ let body = null;
282
+ try {
283
+ const res = await fetchFn(`${relayBase}/escalation/response/${encodeURIComponent(requestId)}`, {
284
+ headers: relayAuthHeader(pairing),
285
+ signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000),
286
+ });
287
+ if (res && res.ok) body = await res.json();
288
+ } catch { /* transient */ }
289
+ if (!body) return { resolved: false, status: 'pending' };
290
+ const data = await unwrapEscalationResponse(pairing.pair_key, body);
291
+ if (!data || !DECISION_OUTCOME[data.decision]) return { resolved: false, status: 'pending' };
292
+ return {
293
+ resolved: true,
294
+ decision: data.decision,
295
+ proceed: data.decision !== 'deny',
296
+ note: typeof data.note === 'string' ? data.note : undefined,
297
+ modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
298
+ outcome: DECISION_OUTCOME[data.decision],
299
+ request_id: requestId,
300
+ };
301
+ }
302
+
303
+ // The SAFE DEFAULT applied when the window lapses with no answer (roadmap §crux).
304
+ // `escalate` already means "hold for a human", so absent a decision the agent must
305
+ // NOT proceed — it holds. A timeout is not an override, so it teaches nothing
306
+ // (outcome 'unknown'). Returned in the same shape as a real decision so the caller
307
+ // has one code path.
308
+ function safeDefault(signal) {
309
+ return {
310
+ decision: 'hold',
311
+ resolved: false,
312
+ reason: 'timeout',
313
+ proceed: false, // do-not-proceed is the safe default for a blocking escalate/clarify
314
+ outcome: 'unknown', // no answer ⇒ no act-as-me signal
315
+ signal,
316
+ };
317
+ }
318
+
319
+ // Orchestrate one real-time escalation: deposit → poll within the window → decision
320
+ // or safe-default. Best-effort and self-contained — every failure path resolves to a
321
+ // safe-default rather than throwing into the agent. `deps.fetch`, `deps.sleep`,
322
+ // `deps.now`, `deps.pollMs`, `deps.windowMs`, `deps.relayBase` are injectable for tests.
323
+ //
324
+ // pairing : { pair_key, relay_device_id } (loadPairingRecord)
325
+ // consultRequest : the args passed to phaedo_consult
326
+ // consultResponse : the §10.3 response it returned (must be escalate/clarify)
327
+ // Returns:
328
+ // { resolved, decision, proceed, note?, outcome, request_id, signal, reason? }
329
+ export async function requestEscalationDecision(pairing, consultRequest, consultResponse, deps = {}) {
330
+ const fetchFn = deps.fetch || globalThis.fetch;
331
+ const wait = deps.sleep || sleep;
332
+ const now = deps.now || (() => Date.now());
333
+ const signal = consultResponse && consultResponse.signal;
334
+
335
+ if (!pairing || !pairing.pair_key) return { ...safeDefault(signal), reason: 'unpaired' };
336
+ if (!pairing.relay_device_id) return { ...safeDefault(signal), reason: 'no-relay-device-id' };
337
+
338
+ const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
339
+ const pollMs = Math.max(250, Number(deps.pollMs) || 3000);
340
+ const relayBase = deps.relayBase || relayBaseFromEnv();
341
+ const requestId = deps.requestId || genRequestId();
342
+
343
+ // 1. Deposit the (encrypted) decision request; the relay wakes the device.
344
+ try {
345
+ const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
346
+ const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
349
+ body: JSON.stringify(body),
350
+ signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
351
+ });
352
+ if (!res || !res.ok) return { ...safeDefault(signal), reason: `deposit-failed-${res && res.status}`, request_id: requestId };
353
+ } catch (e) {
354
+ return { ...safeDefault(signal), reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId };
355
+ }
356
+
357
+ // 2. Poll for the decision until the window lapses.
358
+ const deadline = now() + windowMs;
359
+ const pollUrl = `${relayBase}/escalation/response/${encodeURIComponent(requestId)}`;
360
+ while (now() < deadline) {
361
+ await wait(pollMs);
362
+ let body = null;
363
+ try {
364
+ const res = await fetchFn(pollUrl, { headers: relayAuthHeader(pairing), signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000) });
365
+ if (res && res.ok) body = await res.json();
366
+ } catch { /* transient — keep polling */ }
367
+ if (!body) continue;
368
+ const data = await unwrapEscalationResponse(pairing.pair_key, body);
369
+ if (!data || !DECISION_OUTCOME[data.decision]) continue; // ignore unparseable/unknown decisions; keep waiting
370
+ return {
371
+ resolved: true,
372
+ decision: data.decision,
373
+ proceed: data.decision !== 'deny',
374
+ note: typeof data.note === 'string' ? data.note : undefined,
375
+ modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
376
+ outcome: DECISION_OUTCOME[data.decision],
377
+ request_id: requestId,
378
+ signal,
379
+ };
380
+ }
381
+ // 3. Window lapsed — safe default (hold; no learning).
382
+ return { ...safeDefault(signal), request_id: requestId };
383
+ }