@junghanacs/entwurf 0.41.1 → 0.45.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/AGENTS.md +2 -1
- package/BASELINE.md +7 -5
- package/CHANGELOG.md +344 -0
- package/DELIVERY.md +37 -20
- package/ENTWURF.json +236 -0
- package/README.md +75 -65
- package/VERIFY.md +9 -9
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +59 -33
- package/mcp/entwurf-bridge/dist/ENTWURF.json +236 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -190
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +23 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +15 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +29 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +101 -32
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +23 -45
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
- package/mcp/entwurf-bridge/src/index.ts +126 -321
- package/native/win32-x64/entwurf_win.node +0 -0
- package/native/win32-x64/manifest.json +2 -2
- package/package.json +5 -4
- package/pi-extensions/lib/agent-guidance.ts +341 -0
- package/pi-extensions/lib/entwurf-self-address.ts +13 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
- package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +27 -30
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +15 -16
- package/pi-extensions/lib/fresh-call-composition.ts +21 -10
- package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
- package/pi-extensions/lib/meta-sender-identity.ts +4 -0
- package/pi-extensions/lib/mux-fresh-call.ts +29 -30
- package/pi-extensions/lib/mux-resume-call.ts +18 -19
- package/pi-extensions/lib/native-push/register.ts +17 -2
- package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
- package/pi-extensions/lib/remote-pair-receive.ts +27 -19
- package/pi-extensions/lib/remote-pair-send.ts +124 -34
- package/pi-extensions/meta-bridge-omp.ts +69 -7
- package/pi-extensions/meta-bridge-pi-durable.ts +23 -64
- package/run.sh +182 -22
- package/scripts/build-bridge.sh +10 -0
- package/scripts/check-agy-sender-identity.ts +6 -4
- package/scripts/check-capability-bundle-reach.ts +86 -0
- package/scripts/check-entwurf-self-address.ts +37 -6
- package/scripts/check-entwurf-v2-contract.ts +24 -2
- package/scripts/check-entwurf-v2-production.ts +45 -31
- package/scripts/check-entwurf-v2-surface.ts +94 -98
- package/scripts/check-gate-qualification.ts +17 -13
- package/scripts/check-mux-resume-call.ts +20 -3
- package/scripts/check-native-push-adapter.ts +4 -0
- package/scripts/check-native-push-register.ts +32 -82
- package/scripts/check-remote-pair-installed.ts +2 -2
- package/scripts/dev-bin.sh +112 -1
- package/scripts/lib/chain-terminus-publication.ts +58 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +30 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
- package/scripts/mutants/agent-guidance.json +160 -0
- package/scripts/mutants/bridge-boot-resume.json +6 -8
- package/scripts/mutants/capability-bundle-reach.json +124 -0
- package/scripts/mutants/chain-terminus-publication.json +17 -0
- package/scripts/mutants/codex-native.json +98 -0
- package/scripts/mutants/control-send-receipt.json +23 -6
- package/scripts/mutants/mux-fresh-call.json +30 -4
- package/scripts/mutants/mux-resume-call.json +28 -3
- package/scripts/mutants/native-push-paused.json +17 -0
- package/scripts/mutants/omp-fresh.json +13 -2
- package/scripts/mutants/pack-install.json +46 -0
- package/scripts/mutants/pi-durable.json +17 -5
- package/scripts/mutants/remote-pair-display.json +3 -3
- package/scripts/mutants/remote-pair-receive.json +41 -11
- package/scripts/mutants/remote-pair-roundtrip.json +67 -7
- package/scripts/mutants/remote-pair-send.json +177 -19
- package/scripts/mutants/remote-pair-transport.json +4 -4
- package/scripts/mutants/self-address.json +10 -10
- package/scripts/mutants/source-install.json +66 -0
- package/scripts/mutants/v2-surface.json +50 -64
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/remote-pair-receive.ts +12 -12
- package/scripts/smoke-agy-install-state.sh +121 -0
- package/scripts/smoke-agy-native-push-live.ts +13 -0
- package/scripts/smoke-codex-fresh-live.ts +50 -27
- package/scripts/smoke-entwurf-chain-live.ts +7 -9
- package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
- package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
- package/scripts/smoke-mux-lifecycle-live.ts +140 -8
- package/scripts/smoke-omp-fresh-live.ts +5 -4
- package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
- package/scripts/smoke-setup-verdict.sh +27 -2
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* remote-pair-receive — the RECEIVING half of
|
|
2
|
+
* remote-pair-receive — the RECEIVING half of the declared remote pairs (#107 rail 1, #149 routes).
|
|
3
3
|
*
|
|
4
4
|
* The host that owns the target record runs this once per SSH call. It reads one JSON claim
|
|
5
|
-
* from stdin, checks it against the operator-written pair
|
|
5
|
+
* from stdin, checks it against the operator-written pair declarations, and hands a matching
|
|
6
6
|
* claim to the existing v2 dispatch. The record, owner and deliverability checks stay the
|
|
7
7
|
* dispatch's own: nothing here writes a mailbox, resolves a rail, or retries.
|
|
8
8
|
*
|
|
9
9
|
* Trust model (GLG, #107 D3 (a)): the boundary is the same operator's SSH account. The pair
|
|
10
|
-
* check is a SCOPE limit, not authentication: a claim whose `from`/`target` is not
|
|
11
|
-
* pair is refused before any mutation.
|
|
12
|
-
* declared
|
|
10
|
+
* check is a SCOPE limit, not authentication: a claim whose `from`/`target` is not one declared
|
|
11
|
+
* pair is refused before any mutation. Both ends are matched in the SAME route (#149), so a sender
|
|
12
|
+
* declared in one route cannot address the local citizen of another. It does not detect that same
|
|
13
|
+
* account sending a declared gid falsely; nothing here claims to.
|
|
13
14
|
*
|
|
14
15
|
* Sender: the stdin claim is the only sender input. Ambient carriers (`PI_SESSION_ID`,
|
|
15
16
|
* `PI_AGENT_ID`, sender markers) in the SSH environment are never consulted, so this module
|
|
@@ -86,21 +87,28 @@ export function parseRemoteClaim(text) {
|
|
|
86
87
|
wantsReply: obj.wantsReply,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The scope check: the one declared pair whose two ends are the claim's `from` and `target`, or a
|
|
92
|
+
* refusal. The two reasons are the ones every sender already reads (#107), so a sender on another
|
|
93
|
+
* release still classifies them: an undeclared `from` is a from-mismatch; a declared `from` with a
|
|
94
|
+
* target that is not ITS declared local (another route's local included) is a target-mismatch.
|
|
95
|
+
*/
|
|
96
|
+
export function selectRemotePair(claim, decls) {
|
|
97
|
+
const fromRoutes = decls.filter((d) => d.remoteGardenId === claim.from);
|
|
98
|
+
if (fromRoutes.length === 0) {
|
|
92
99
|
return {
|
|
93
100
|
reason: "remote-pair-from-mismatch",
|
|
94
|
-
detail: `from ${claim.from} is not
|
|
101
|
+
detail: `from ${claim.from} is not a declared remote (${decls.map((d) => d.remoteGardenId).join(", ")})`,
|
|
95
102
|
};
|
|
96
103
|
}
|
|
97
|
-
|
|
104
|
+
const decl = fromRoutes.find((d) => d.localGardenId === claim.target);
|
|
105
|
+
if (!decl) {
|
|
98
106
|
return {
|
|
99
107
|
reason: "remote-pair-target-mismatch",
|
|
100
|
-
detail: `target ${claim.target} is not
|
|
108
|
+
detail: `target ${claim.target} is not a declared local for remote ${claim.from} (${fromRoutes.map((d) => d.localGardenId).join(", ")})`,
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
|
-
return
|
|
111
|
+
return { decl };
|
|
104
112
|
}
|
|
105
113
|
export function buildRemoteSenderEnvelope(claim, decl, now, replyRoute = "none") {
|
|
106
114
|
return {
|
|
@@ -113,11 +121,11 @@ export function buildRemoteSenderEnvelope(claim, decl, now, replyRoute = "none")
|
|
|
113
121
|
remote: { via: decl.ssh, replyRoute },
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
|
-
export async function receiveRemoteClaim(claim,
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
return { kind: "rejected", ...
|
|
120
|
-
const sender = buildRemoteSenderEnvelope(claim, decl, (deps.now ?? (() => new Date()))(), deps.replyRoute);
|
|
124
|
+
export async function receiveRemoteClaim(claim, decls, deps) {
|
|
125
|
+
const picked = selectRemotePair(claim, decls);
|
|
126
|
+
if (!("decl" in picked))
|
|
127
|
+
return { kind: "rejected", ...picked };
|
|
128
|
+
const sender = buildRemoteSenderEnvelope(claim, picked.decl, (deps.now ?? (() => new Date()))(), deps.replyRoute);
|
|
121
129
|
const rendered = await deps.dispatch({ target: claim.target, intent: "fire-and-forget", message: claim.message, wants_reply: claim.wantsReply }, sender);
|
|
122
130
|
return { kind: "dispatched", isError: rendered.isError, text: rendered.text };
|
|
123
131
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* remote-pair-send — the SENDING half of
|
|
2
|
+
* remote-pair-send — the SENDING half of the declared remote pairs (#107 rail 2a, #149 routes).
|
|
3
3
|
*
|
|
4
4
|
* It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* no route names as remote, the local path runs unchanged. When a route names the target, any local
|
|
6
|
+
* trace of that gid (a record entry in any state, or anything at its control socket path) fails
|
|
7
|
+
* closed as an ambiguity before transport. Otherwise the call runs over one transport call: no
|
|
8
|
+
* synthetic record, no new backend, no new rail plan, and no retry.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Selection (#149): a route is one exact (local sender, remote target) pair. The route used is the
|
|
11
|
+
* one whose two ends are the caller and the target; a target declared remote for some other sender
|
|
12
|
+
* is refused, never sent under another route and never handed to the local path.
|
|
13
|
+
*
|
|
14
|
+
* Sender: only the caller's authoritative envelope becomes the claim. An anonymous envelope, or a
|
|
15
|
+
* gid no route pairs with the target, is refused before transport. The model chooses neither host
|
|
16
|
+
* nor command: both come from the operator's declaration.
|
|
13
17
|
*
|
|
14
18
|
* Remote command: ssh joins the remote words into one shell string. So the alias and the remote
|
|
15
19
|
* declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
|
|
@@ -21,6 +25,7 @@
|
|
|
21
25
|
*/
|
|
22
26
|
import * as fs from "node:fs";
|
|
23
27
|
import * as path from "node:path";
|
|
28
|
+
import { ENTWURF_DELIVERY_RESULT_CLAUSES } from "./agent-guidance.js";
|
|
24
29
|
import { controlSocketPathIn } from "./control-socket-path.js";
|
|
25
30
|
import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.js";
|
|
26
31
|
import { inspectControlSocketPath } from "./socket-discovery.js";
|
|
@@ -31,24 +36,23 @@ const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
|
31
36
|
const CONNECT_TIMEOUT_SECONDS = 10;
|
|
32
37
|
export const REMOTE_SEND_TIMEOUT_MS = 30_000;
|
|
33
38
|
const PAIR_REJECT_REASONS = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
raw = JSON.parse(text);
|
|
39
|
-
}
|
|
40
|
-
catch (err) {
|
|
41
|
-
throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
42
|
-
}
|
|
39
|
+
/** The one declaration version this reader accepts. The versionless single-pair form is refused. */
|
|
40
|
+
export const REMOTE_PAIR_DECLARATION_VERSION = 2;
|
|
41
|
+
function requireObject(raw, what) {
|
|
43
42
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
44
43
|
throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
function requireKeys(obj, want, what) {
|
|
48
48
|
const got = Object.keys(obj).sort();
|
|
49
49
|
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
50
50
|
throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
|
|
51
51
|
}
|
|
52
|
+
}
|
|
53
|
+
function parseRoute(raw, what) {
|
|
54
|
+
const obj = requireObject(raw, what);
|
|
55
|
+
requireKeys(obj, ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"], what);
|
|
52
56
|
const { ssh, remotePairFile } = obj;
|
|
53
57
|
if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
|
|
54
58
|
throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
|
|
@@ -65,6 +69,66 @@ export function parseRemoteRouteDeclaration(text) {
|
|
|
65
69
|
remotePairFile,
|
|
66
70
|
};
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The set rules, decided on the whole file and independent of route order: nothing is chosen by
|
|
74
|
+
* position. Legal repeats: one local gid paired with several remotes, one remote gid paired with
|
|
75
|
+
* several locals, one alias carrying several routes. Refused: the same exact pair twice; a gid that
|
|
76
|
+
* is local in one route and remote in another (including a route to itself); one remote gid reached
|
|
77
|
+
* through two different aliases or files; one alias naming two different remote files, since the
|
|
78
|
+
* receive entry on that host accepts only its one canonical file.
|
|
79
|
+
*/
|
|
80
|
+
function checkRouteSet(routes, what) {
|
|
81
|
+
const locals = new Set(routes.map((r) => r.localGardenId));
|
|
82
|
+
const pairs = new Set();
|
|
83
|
+
const remoteHost = new Map();
|
|
84
|
+
const aliasFile = new Map();
|
|
85
|
+
routes.forEach((r, i) => {
|
|
86
|
+
const at = `${what}: routes[${i}]`;
|
|
87
|
+
const pair = `${r.localGardenId} -> ${r.remoteGardenId}`;
|
|
88
|
+
if (pairs.has(pair)) {
|
|
89
|
+
throw new RemoteRouteDeclarationError(`${at}: duplicate-pair: ${pair} is declared more than once`);
|
|
90
|
+
}
|
|
91
|
+
pairs.add(pair);
|
|
92
|
+
if (locals.has(r.remoteGardenId)) {
|
|
93
|
+
throw new RemoteRouteDeclarationError(`${at}: local-remote-overlap: ${r.remoteGardenId} is declared remote here and local in a route of this file`);
|
|
94
|
+
}
|
|
95
|
+
const host = remoteHost.get(r.remoteGardenId);
|
|
96
|
+
if (host && (host.ssh !== r.ssh || host.remotePairFile !== r.remotePairFile)) {
|
|
97
|
+
throw new RemoteRouteDeclarationError(`${at}: remote-host-conflict: ${r.remoteGardenId} is declared via ${host.ssh} ${host.remotePairFile} and via ${r.ssh} ${r.remotePairFile}`);
|
|
98
|
+
}
|
|
99
|
+
remoteHost.set(r.remoteGardenId, host ?? r);
|
|
100
|
+
const file = aliasFile.get(r.ssh);
|
|
101
|
+
if (file !== undefined && file !== r.remotePairFile) {
|
|
102
|
+
throw new RemoteRouteDeclarationError(`${at}: alias-file-conflict: alias ${r.ssh} names both ${file} and ${r.remotePairFile}`);
|
|
103
|
+
}
|
|
104
|
+
aliasFile.set(r.ssh, file ?? r.remotePairFile);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function parseRemotePairDeclaration(text) {
|
|
108
|
+
const what = "remote pair declaration";
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = JSON.parse(text);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
115
|
+
}
|
|
116
|
+
const obj = requireObject(raw, what);
|
|
117
|
+
if (!Object.hasOwn(obj, "version")) {
|
|
118
|
+
throw new RemoteRouteDeclarationError(`${what}: no "version" — the versionless single-pair form is no longer read. Rewrite it by hand as ` +
|
|
119
|
+
`{"version":${REMOTE_PAIR_DECLARATION_VERSION},"routes":[<the four-key object>]} (setup §2c)`);
|
|
120
|
+
}
|
|
121
|
+
requireKeys(obj, ["routes", "version"], what);
|
|
122
|
+
if (obj.version !== REMOTE_PAIR_DECLARATION_VERSION) {
|
|
123
|
+
throw new RemoteRouteDeclarationError(`${what}: "version" must be ${REMOTE_PAIR_DECLARATION_VERSION} (got ${JSON.stringify(obj.version)})`);
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(obj.routes) || obj.routes.length === 0) {
|
|
126
|
+
throw new RemoteRouteDeclarationError(`${what}: "routes" must be a non-empty array; remove the file to switch off`);
|
|
127
|
+
}
|
|
128
|
+
const routes = obj.routes.map((r, i) => parseRoute(r, `${what}: routes[${i}]`));
|
|
129
|
+
checkRouteSet(routes, what);
|
|
130
|
+
return { routes };
|
|
131
|
+
}
|
|
68
132
|
/** The one argv the transport runs. Every word is inside the no-quoting charset above. */
|
|
69
133
|
export function remoteReceiveArgv(route) {
|
|
70
134
|
return [
|
|
@@ -141,13 +205,15 @@ export function classifyRemoteReceipt(r) {
|
|
|
141
205
|
}
|
|
142
206
|
return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
|
|
143
207
|
}
|
|
208
|
+
// The three static explanatory clauses are authored in root ENTWURF.json (#154 E1a); the receipt
|
|
209
|
+
// syntax, the route label, the slots and the refused/invalid lines stay here.
|
|
144
210
|
function render(route, o) {
|
|
145
211
|
const via = `via ${route.ssh}`;
|
|
146
212
|
switch (o.kind) {
|
|
147
213
|
case "remote-accepted":
|
|
148
214
|
return {
|
|
149
215
|
isError: false,
|
|
150
|
-
text: `entwurf_v2 remote ${via} → accepted
|
|
216
|
+
text: `entwurf_v2 remote ${via} → ${ENTWURF_DELIVERY_RESULT_CLAUSES["remote-accepted"]}\n${o.text}`,
|
|
151
217
|
};
|
|
152
218
|
case "remote-reported-error":
|
|
153
219
|
// The other host's dispatch returned isError. That can follow a write (an enqueue whose
|
|
@@ -155,7 +221,7 @@ function render(route, o) {
|
|
|
155
221
|
// non-delivery nor safety to resend: the other host's own text below is the fact.
|
|
156
222
|
return {
|
|
157
223
|
isError: true,
|
|
158
|
-
text: `entwurf_v2 remote ${via} →
|
|
224
|
+
text: `entwurf_v2 remote ${via} → ${ENTWURF_DELIVERY_RESULT_CLAUSES["remote-reported-error"]}\n${o.text}`,
|
|
159
225
|
};
|
|
160
226
|
case "remote-pair-refused":
|
|
161
227
|
return {
|
|
@@ -167,8 +233,8 @@ function render(route, o) {
|
|
|
167
233
|
case "remote-indeterminate":
|
|
168
234
|
return {
|
|
169
235
|
isError: true,
|
|
170
|
-
text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}.
|
|
171
|
-
|
|
236
|
+
text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}. ` +
|
|
237
|
+
`${ENTWURF_DELIVERY_RESULT_CLAUSES["remote-indeterminate"]}${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
|
|
172
238
|
};
|
|
173
239
|
}
|
|
174
240
|
}
|
|
@@ -176,22 +242,25 @@ function refuse(text) {
|
|
|
176
242
|
return { isError: true, text: `entwurf_v2 rejected: ${text}` };
|
|
177
243
|
}
|
|
178
244
|
export async function runEntwurfV2WithRemoteRoute(params, opts) {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
245
|
+
const declared = (opts.optIn?.routes ?? []).filter((r) => r.remoteGardenId === params.target);
|
|
246
|
+
if (declared.length === 0) {
|
|
181
247
|
return opts.runLocal(params, { senderProvider: opts.senderProvider });
|
|
182
248
|
}
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
// One remote gid has one alias and file (checkRouteSet), so any declared route names its host.
|
|
250
|
+
const via = `via ${declared[0].ssh}`;
|
|
251
|
+
if (await opts.localTrace(params.target)) {
|
|
252
|
+
return refuse(`remote-route-local-conflict: ${params.target} is declared remote ${via} but this host also has a local trace of it; not choosing`);
|
|
185
253
|
}
|
|
186
254
|
const sender = opts.senderProvider();
|
|
187
|
-
|
|
188
|
-
|
|
255
|
+
const route = sender && sender.origin !== "external-mcp" ? declared.find((r) => r.localGardenId === sender.sessionId) : undefined;
|
|
256
|
+
if (!sender || !route) {
|
|
257
|
+
return refuse(`remote-sender-not-declared: only ${declared.map((r) => r.localGardenId).join(", ")} may send to ${params.target} ${via} (sender: ${sender ? sender.sessionId : "none"}); not sent, and not handed to the local path`);
|
|
189
258
|
}
|
|
190
259
|
const claim = {
|
|
191
260
|
from: sender.sessionId,
|
|
192
261
|
fromAgentId: sender.agentId,
|
|
193
262
|
fromCwd: sender.cwd,
|
|
194
|
-
target:
|
|
263
|
+
target: params.target,
|
|
195
264
|
message: params.message,
|
|
196
265
|
wantsReply: params.wants_reply ?? false,
|
|
197
266
|
};
|
|
@@ -218,7 +287,7 @@ export function makeLocalTraceFact(dirs) {
|
|
|
218
287
|
return false;
|
|
219
288
|
};
|
|
220
289
|
}
|
|
221
|
-
/** The operator's opt-in file name. One per host; it declares that host's
|
|
290
|
+
/** The operator's opt-in file name. One per host; it declares that host's remote routes. */
|
|
222
291
|
export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
|
|
223
292
|
/**
|
|
224
293
|
* Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
|
|
@@ -251,7 +320,7 @@ export function readRemotePairOptIn(file) {
|
|
|
251
320
|
throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
|
|
252
321
|
}
|
|
253
322
|
try {
|
|
254
|
-
return
|
|
323
|
+
return parseRemotePairDeclaration(fs.readFileSync(file, "utf8"));
|
|
255
324
|
}
|
|
256
325
|
catch (err) {
|
|
257
326
|
throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
import * as fs from "node:fs";
|
|
79
79
|
import * as path from "node:path";
|
|
80
80
|
import { isPlausibleOwnerPid, ompMetaRootBase, ompMetaRoots, upsertMetaSession, writeMetaSenderMarker, } from "./lib/meta-session.js";
|
|
81
|
+
import { SESSION_ID_RE } from "./lib/session-id.js";
|
|
81
82
|
/**
|
|
82
83
|
* The §3.5 host discriminator. THE one new predicate this whole lane is allowed to
|
|
83
84
|
* introduce, and it is the vendor's own top-level mode — not a heuristic over cwd,
|
|
@@ -137,9 +138,19 @@ const BACKEND = "omp";
|
|
|
137
138
|
// byte-identical — quotes, `$VAR`, backticks and a semicolon all survived a 137-byte JSON
|
|
138
139
|
// payload — because extensions load BEFORE argv classification and `applyExtensionFlags`
|
|
139
140
|
// reparses with the registered map (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`,
|
|
140
|
-
// `extensions/loader.ts:221-228`). That is also why
|
|
141
|
+
// `extensions/loader.ts:221-228`). That is also why the TASK never rides `process.env` or a
|
|
141
142
|
// temp file: argv owns its own quoting, and a general env/command carrier would hand callers
|
|
142
143
|
// the environment-shaping power the fresh rail exists to refuse.
|
|
144
|
+
//
|
|
145
|
+
// ONE NARROW, NAMED EXCEPTION, AND IT IS NOT A CARRIER FOR ANYTHING NEW. `[GLG 직접,
|
|
146
|
+
// 2026-10-24]` a fresh sibling checks the caller and THEN calls back. To say who the caller is,
|
|
147
|
+
// stage one reads exactly one existing name, `ENTWURF_CALLBACK_TARGET` — the caller garden id the
|
|
148
|
+
// launcher already resolved from the record and injected beside the identity scrub for the
|
|
149
|
+
// callback verb (`lib/callback-env.ts`). It is read for DISPLAY ONLY: the callback still takes no
|
|
150
|
+
// arguments, the bridge child still reads its own copy of the pair, and the nonce is never read
|
|
151
|
+
// here or shown. It is read only when the bootstrap flag is present, so a manual `omp` gains no
|
|
152
|
+
// new requirement; and a requested bootstrap whose target is absent or malformed is REFUSED by
|
|
153
|
+
// name and its task never sent, rather than silently dropping the check.
|
|
143
154
|
// ---------------------------------------------------------------------------
|
|
144
155
|
/** The ONE argv flag this unit owns. Registered without the leading dashes — the vendor's
|
|
145
156
|
* flag map is keyed by bare name (`extensions/loader.ts:221-228`). */
|
|
@@ -162,6 +173,23 @@ export const OMP_BOOTSTRAP_VERSION = 2;
|
|
|
162
173
|
* `check-omp-fresh-preflight` already uses for the preflight's reproduced oracles.
|
|
163
174
|
*/
|
|
164
175
|
export const OMP_BOOTSTRAP_CALLBACK_TOOL = "mcp__entwurf_bridge_entwurf_callback";
|
|
176
|
+
/** The read-only listing stage one offers for checking the caller, spelled for omp. Duplicated
|
|
177
|
+
* from `FRESH_CALL_PEERS_TOOL.omp` for the same closure reason as the callback tool and held
|
|
178
|
+
* equal by the same contract test. */
|
|
179
|
+
export const OMP_BOOTSTRAP_PEERS_TOOL = "mcp__entwurf_bridge_entwurf_peers";
|
|
180
|
+
/** The ONE env name stage one reads (display only — see the exception above). Duplicated from
|
|
181
|
+
* `CALLBACK_TARGET_ENV` (`lib/callback-env.ts`) for the closure reason above and held equal by
|
|
182
|
+
* the contract test. The nonce's name is deliberately not here. */
|
|
183
|
+
export const OMP_CALLBACK_TARGET_ENV = "ENTWURF_CALLBACK_TARGET";
|
|
184
|
+
/** Read the caller garden id a launch put beside this host, by the record's own grammar. */
|
|
185
|
+
export function readOmpCallbackTarget(env) {
|
|
186
|
+
const target = env[OMP_CALLBACK_TARGET_ENV];
|
|
187
|
+
if (target === undefined)
|
|
188
|
+
return { ok: false, reason: "callback-target-absent" };
|
|
189
|
+
if (!SESSION_ID_RE.test(target))
|
|
190
|
+
return { ok: false, reason: "callback-target-malformed" };
|
|
191
|
+
return { ok: true, target };
|
|
192
|
+
}
|
|
165
193
|
/** Task ceiling, held equal to the launcher's `TASK_MAX_CHARS` by the same contract test. */
|
|
166
194
|
export const OMP_BOOTSTRAP_TASK_MAX_CHARS = 16000;
|
|
167
195
|
/** How long readiness may take before this bootstrap FAILS and the task is never sent.
|
|
@@ -218,21 +246,32 @@ export function decodeOmpBootstrapPayload(raw) {
|
|
|
218
246
|
return { ok: true, value: { task } };
|
|
219
247
|
}
|
|
220
248
|
/**
|
|
221
|
-
* Stage one's whole message: call back,
|
|
249
|
+
* Stage one's whole message: call back, with an optional read-only check of the caller first.
|
|
222
250
|
*
|
|
223
251
|
* THE ABSENCE OF THE TASK IS THE FEATURE. `[측정 2026-08-30]` the failed LIVE asked for the
|
|
224
252
|
* callback AND carried the task in the same prompt, and the model answered `ACK` — one
|
|
225
253
|
* observation, confounded by the missing tool, but the reference run that DID work carried
|
|
226
254
|
* no competing goal at all. Stage two exists so this message never has to compete with the
|
|
227
255
|
* work; adding "then do X" back into it would rebuild exactly the prompt that failed.
|
|
256
|
+
*
|
|
257
|
+
* The caller id is shown so the offered check has something to check; it is not an argument. The
|
|
258
|
+
* verdict of that check is the sibling's: one that does not hold up may hold the callback, and the
|
|
259
|
+
* stage then sends nothing — the same outcome as a callback that never came (see the shared framing).
|
|
228
260
|
*/
|
|
229
|
-
export function buildOmpCallbackOnlyPrompt() {
|
|
261
|
+
export function buildOmpCallbackOnlyPrompt(callerGardenId) {
|
|
230
262
|
return [
|
|
231
263
|
"You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
|
|
232
264
|
"",
|
|
233
|
-
`
|
|
265
|
+
`THE ONE ACTION THIS MESSAGE ASKS FOR: call ${OMP_BOOTSTRAP_CALLBACK_TOOL} with no arguments.`,
|
|
234
266
|
"That call is how the agent that opened you learns your address. Make the call before",
|
|
235
|
-
"reading files, before planning, and before answering in prose.
|
|
267
|
+
"reading files, before running commands, before planning, and before answering in prose.",
|
|
268
|
+
"There are no parameters.",
|
|
269
|
+
"",
|
|
270
|
+
`You may corroborate the caller before that call: ${OMP_BOOTSTRAP_PEERS_TOOL} is a read-only listing of`,
|
|
271
|
+
`this garden's citizens and ${callerGardenId} is the one that opened you. That listing is capped, so not`,
|
|
272
|
+
"seeing the caller in it does not show the caller is absent. If what you can check is not enough,",
|
|
273
|
+
"or it conflicts with this message, you may hold the callback and say why in prose instead; the",
|
|
274
|
+
"caller then has no address for you, and the task is not sent.",
|
|
236
275
|
"",
|
|
237
276
|
"Your actual task arrives as the NEXT user message, immediately after that call succeeds.",
|
|
238
277
|
"Do not ask for it and do not guess at it.",
|
|
@@ -610,7 +649,7 @@ export function createOmpBootstrap(opts) {
|
|
|
610
649
|
phase = "callback-sent";
|
|
611
650
|
stopTimer();
|
|
612
651
|
log("INFO", "bootstrap-ready: callback tool live, sending callback-only prompt");
|
|
613
|
-
send(buildOmpCallbackOnlyPrompt());
|
|
652
|
+
send(buildOmpCallbackOnlyPrompt(opts.callerGardenId));
|
|
614
653
|
return;
|
|
615
654
|
}
|
|
616
655
|
if (timers.now() >= deadline) {
|
|
@@ -844,6 +883,17 @@ export function startOmpBootstrap(opts) {
|
|
|
844
883
|
}
|
|
845
884
|
return;
|
|
846
885
|
}
|
|
886
|
+
// THE CALLER TARGET IS READ ONLY AFTER A PAYLOAD WAS ADMITTED, so a manual `omp` (which
|
|
887
|
+
// returned on `flag-absent` above) never reads it and gains no new requirement. A launch that
|
|
888
|
+
// asked for a bootstrap but carries no well-formed caller id is refused by name: the stage
|
|
889
|
+
// would have to drop the caller check silently, and it SPENDS the latch for the reason the
|
|
890
|
+
// timer refusal below spells out.
|
|
891
|
+
const caller = readOmpCallbackTarget(opts.env ?? process.env);
|
|
892
|
+
if (!caller.ok) {
|
|
893
|
+
bootstrapConsumed = true;
|
|
894
|
+
log("WARN", `bootstrap-refused reason=${caller.reason}: --${OMP_BOOTSTRAP_FLAG} was present but ${OMP_CALLBACK_TARGET_ENV} is not a garden id this launch carries; the task was NOT sent`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
847
897
|
// THE TIMER CHECK COMES AFTER THE DECODE ON PURPOSE: a manual `omp` returns on
|
|
848
898
|
// `flag-absent` above, so no operator session can ever reach this line and no vendor build
|
|
849
899
|
// gets a WARN it has no launch to explain.
|
|
@@ -859,7 +909,7 @@ export function startOmpBootstrap(opts) {
|
|
|
859
909
|
}
|
|
860
910
|
// A replacement session takes the lane; the previous bootstrap dies without sending.
|
|
861
911
|
activeBootstrap?.handle.invalidate(`replaced by native session ${envelope.nativeSessionId}`);
|
|
862
|
-
const handle = createOmpBootstrap({ payload: decoded.value, pi, timers, log });
|
|
912
|
+
const handle = createOmpBootstrap({ payload: decoded.value, callerGardenId: caller.target, pi, timers, log });
|
|
863
913
|
activeBootstrap = { sessionId: envelope.nativeSessionId, handle };
|
|
864
914
|
bootstrapConsumed = true;
|
|
865
915
|
log("INFO", "bootstrap-armed: payload admitted, waiting for callback tool");
|
|
@@ -114,6 +114,7 @@ import * as fs from "node:fs";
|
|
|
114
114
|
import * as path from "node:path";
|
|
115
115
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
116
116
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
117
|
+
import { ENTWURF_TOOL_GUIDANCE } from "./lib/agent-guidance.js";
|
|
117
118
|
import { defaultMetaMailboxDir, defaultMetaReceiversDir, defaultMetaSendersDir, defaultMetaSessionsDir, listAllMetaIdentitiesDir, readAddressableMetaIdentity, readMetaReceiverMarker, readMetaSenderMarker, requireGardenId, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
|
|
118
119
|
/** The backend id this contact mints under. */
|
|
119
120
|
export const PI_DURABLE_BACKEND = "pi-durable";
|
|
@@ -162,26 +163,26 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
162
163
|
target: {
|
|
163
164
|
type: "string",
|
|
164
165
|
minLength: 1,
|
|
165
|
-
description: "
|
|
166
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_v2.parameters["target"],
|
|
166
167
|
},
|
|
167
168
|
intent: {
|
|
168
169
|
type: "string",
|
|
169
170
|
enum: ["fire-and-forget"],
|
|
170
|
-
description:
|
|
171
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_v2.parameters["intent"],
|
|
171
172
|
},
|
|
172
173
|
message: {
|
|
173
174
|
type: "string",
|
|
174
175
|
minLength: 1,
|
|
175
176
|
maxLength: 16000,
|
|
176
|
-
description:
|
|
177
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_v2.parameters["message"],
|
|
177
178
|
},
|
|
178
179
|
mode: {
|
|
179
|
-
description:
|
|
180
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_v2.parameters["mode"],
|
|
180
181
|
type: "string",
|
|
181
182
|
enum: ["steer", "follow_up"],
|
|
182
183
|
},
|
|
183
184
|
wants_reply: {
|
|
184
|
-
description: "
|
|
185
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_v2.parameters["wants_reply"],
|
|
185
186
|
type: "boolean",
|
|
186
187
|
},
|
|
187
188
|
},
|
|
@@ -194,7 +195,7 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
194
195
|
gardenId: {
|
|
195
196
|
type: "string",
|
|
196
197
|
minLength: 1,
|
|
197
|
-
description:
|
|
198
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_inbox_read.parameters["gardenId"],
|
|
198
199
|
},
|
|
199
200
|
},
|
|
200
201
|
required: ["gardenId"],
|
|
@@ -207,32 +208,32 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
207
208
|
backend: {
|
|
208
209
|
type: "string",
|
|
209
210
|
enum: ["pi", "claude-code", "copilot", "omp", "codex", "pi-durable"],
|
|
210
|
-
description:
|
|
211
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["backend"],
|
|
211
212
|
},
|
|
212
213
|
model: {
|
|
213
214
|
type: "string",
|
|
214
215
|
minLength: 1,
|
|
215
216
|
maxLength: 200,
|
|
216
217
|
pattern: "^[A-Za-z0-9][A-Za-z0-9._/:\\[\\]-]*$",
|
|
217
|
-
description:
|
|
218
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["model"],
|
|
218
219
|
},
|
|
219
220
|
task: {
|
|
220
221
|
type: "string",
|
|
221
222
|
minLength: 1,
|
|
222
223
|
maxLength: 16000,
|
|
223
|
-
description:
|
|
224
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["task"],
|
|
224
225
|
},
|
|
225
226
|
cwd: {
|
|
226
|
-
description:
|
|
227
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["cwd"],
|
|
227
228
|
type: "string",
|
|
228
229
|
},
|
|
229
230
|
placement: {
|
|
230
|
-
description:
|
|
231
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["placement"],
|
|
231
232
|
type: "object",
|
|
232
233
|
properties: {
|
|
233
234
|
tmuxSession: {
|
|
234
235
|
type: "string",
|
|
235
|
-
description:
|
|
236
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.parameters["placement.tmuxSession"],
|
|
236
237
|
},
|
|
237
238
|
},
|
|
238
239
|
required: ["tmuxSession"],
|
|
@@ -248,7 +249,7 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
248
249
|
type: "string",
|
|
249
250
|
minLength: 1,
|
|
250
251
|
pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
|
|
251
|
-
description:
|
|
252
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_resume_call.parameters["target"],
|
|
252
253
|
},
|
|
253
254
|
},
|
|
254
255
|
required: ["target"],
|
|
@@ -261,7 +262,7 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
261
262
|
type: "string",
|
|
262
263
|
minLength: 1,
|
|
263
264
|
pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
|
|
264
|
-
description: "
|
|
265
|
+
description: ENTWURF_TOOL_GUIDANCE.entwurf_retire_call.parameters["target"],
|
|
265
266
|
},
|
|
266
267
|
},
|
|
267
268
|
required: ["target"],
|
|
@@ -458,37 +459,14 @@ export class PiDurableIdentityNotReady extends Error {
|
|
|
458
459
|
/** The receiver marker's owner kind: the durable host process, which holds the watch AND keys the sender marker. */
|
|
459
460
|
export const PI_DURABLE_RECEIVER_OWNER_KIND = "pi-durable-host";
|
|
460
461
|
const TOOL_DESCRIPTIONS = {
|
|
461
|
-
entwurf_self:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
entwurf_callback: "Call home once, as the first action of a fresh sibling: the entwurf-bridge reads the caller's garden id and " +
|
|
470
|
-
"nonce from this host's launch environment and delivers the nonce to that caller. No arguments. Refuses by " +
|
|
471
|
-
"name when this host was not launched with a callback pair.",
|
|
472
|
-
entwurf_fresh_call: "Open ONE fresh visible sibling beside this durable host through the entwurf-bridge and hand it a first task " +
|
|
473
|
-
"(backend, model and task required; optional cwd and placement). Returns the bridge's LAUNCH receipt or its " +
|
|
474
|
-
"named refusal — not a started runtime, a delivered task or a reply; the sibling's callback names it. Opening " +
|
|
475
|
-
"beside this host needs the tmux pane this host itself was launched in.",
|
|
476
|
-
entwurf_resume_call: "Reopen ONE dormant pi or Claude Code citizen under its own garden id in a visible tmux window through the " +
|
|
477
|
-
"entwurf-bridge (target required; the record supplies the session and cwd — no task, no prompt, no turn). " +
|
|
478
|
-
"Returns the bridge's LAUNCH and OBSERVATION receipts or its named refusal: a live target, another backend, " +
|
|
479
|
-
"or a Claude Code citizen not proven dormant is refused by name, and opening the window needs the tmux " +
|
|
480
|
-
"session this host itself was launched in.",
|
|
481
|
-
entwurf_retire_call: "Only when the human operator explicitly requested this particular target/work: make ONE retirement attempt for that request " +
|
|
482
|
-
"through the bridge (target only, Pi or Claude Code; self retirement refuses). Development work, a role or " +
|
|
483
|
-
"a record-backed actor is NOT a grant. Use host approval where provided/configured; " +
|
|
484
|
-
"Pi and pi-durable have no built-in per-call approval prompt by default. An explicit operator request and " +
|
|
485
|
-
"agent operating policy are required; attribution and MCP annotations are hints, not grants or proof of approval. " +
|
|
486
|
-
"No autonomous cleanup, timers or automatic " +
|
|
487
|
-
"retry of unknown, cancelled or lost results. Do not manually /resume or /clear the target during the request. " +
|
|
488
|
-
"Pi shutdown-requested is only an ACK, not process exit, flush or model silence. Claude Code exited is that " +
|
|
489
|
-
"fd-pinned process only, not conversation end, children or flush. The JSON receipt keeps outcome, record and " +
|
|
490
|
-
"release separate; receipt return does not mean exit, and cancellation does not undo an action. Invoke once " +
|
|
491
|
-
"per explicitly named target, never infer a roster. This contact does not turn hints into authorization.",
|
|
462
|
+
entwurf_self: ENTWURF_TOOL_GUIDANCE.entwurf_self.descriptions.durable,
|
|
463
|
+
entwurf_peers: ENTWURF_TOOL_GUIDANCE.entwurf_peers.descriptions.durable,
|
|
464
|
+
entwurf_v2: ENTWURF_TOOL_GUIDANCE.entwurf_v2.descriptions.durable,
|
|
465
|
+
entwurf_inbox_read: ENTWURF_TOOL_GUIDANCE.entwurf_inbox_read.descriptions.durable,
|
|
466
|
+
entwurf_callback: ENTWURF_TOOL_GUIDANCE.entwurf_callback.descriptions.durable,
|
|
467
|
+
entwurf_fresh_call: ENTWURF_TOOL_GUIDANCE.entwurf_fresh_call.descriptions.durable,
|
|
468
|
+
entwurf_resume_call: ENTWURF_TOOL_GUIDANCE.entwurf_resume_call.descriptions.durable,
|
|
469
|
+
entwurf_retire_call: ENTWURF_TOOL_GUIDANCE.entwurf_retire_call.descriptions.durable,
|
|
492
470
|
};
|
|
493
471
|
/** Announce, never push: the notice names the garden and the tool, never the body. */
|
|
494
472
|
function doorbellNotice(gardenId, unread) {
|