@saadnvd1/agent-os 0.3.697 → 0.3.700
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/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peer/ping/route.ts +10 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/__fixtures__/connect-transport.ts +93 -0
- package/lib/__fixtures__/peer-machine.ts +45 -0
- package/lib/__fixtures__/peer-rpc.ts +224 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +6 -0
- package/lib/done/archive.ts +2 -0
- package/lib/hosts/index.ts +28 -7
- package/lib/hosts/peer-socket.ts +3 -1
- package/lib/hosts/read-capped-bytes.ts +23 -0
- package/lib/hosts/remote-api.ts +112 -41
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +258 -0
- package/lib/peer/rpc-server.ts +369 -0
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +51 -6
- package/lib/security/gate.ts +38 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/sessions/end.ts +3 -0
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +21 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing, on the machine that asks (A): send the hello to the machine the
|
|
3
|
+
* account lists, check its answer is signed by the key the account lists
|
|
4
|
+
* for it, reveal, show the code, then ask every couple of seconds until a
|
|
5
|
+
* person on the other machine has typed it. B then shows a second code, and
|
|
6
|
+
* only that code typed here pins B: the account list alone never does, so a
|
|
7
|
+
* control plane that lists a key of its own for B can't get itself pinned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
11
|
+
import { peerHostAllowed } from "./client";
|
|
12
|
+
import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
|
|
13
|
+
import {
|
|
14
|
+
HELLO_MAX_BYTES,
|
|
15
|
+
commitTo,
|
|
16
|
+
deriveCode,
|
|
17
|
+
parseAck,
|
|
18
|
+
parseAnswer,
|
|
19
|
+
randomNonce,
|
|
20
|
+
sameCode,
|
|
21
|
+
signHello,
|
|
22
|
+
signReveal,
|
|
23
|
+
signStatusAsk,
|
|
24
|
+
typedCode,
|
|
25
|
+
verifyAck,
|
|
26
|
+
verifyAnswer,
|
|
27
|
+
type LinkState,
|
|
28
|
+
} from "./hello";
|
|
29
|
+
import { currentIdentity, loadBoxKey, type Identity } from "./keys";
|
|
30
|
+
import { notifyLinked } from "./notify";
|
|
31
|
+
import { MAX_WRONG, REQUEST_TTL_MS } from "./pairing";
|
|
32
|
+
import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
|
|
33
|
+
|
|
34
|
+
/** `confirm`: B took the code and shows its own, to be typed here. */
|
|
35
|
+
export type OutState = LinkState | "confirm" | "failed";
|
|
36
|
+
|
|
37
|
+
export interface Outgoing {
|
|
38
|
+
id: string;
|
|
39
|
+
machineId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
code: string;
|
|
42
|
+
state: OutState;
|
|
43
|
+
error: string | null;
|
|
44
|
+
hostId: string | null;
|
|
45
|
+
attemptsLeft: number;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Live extends Outgoing {
|
|
51
|
+
peer: AccountPeer;
|
|
52
|
+
requestId: string;
|
|
53
|
+
bBox: string;
|
|
54
|
+
confirmCode: string;
|
|
55
|
+
timer: NodeJS.Timeout | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Post = (
|
|
59
|
+
hostname: string,
|
|
60
|
+
path: string,
|
|
61
|
+
body: object
|
|
62
|
+
) => Promise<{ status: number; json: unknown }>;
|
|
63
|
+
|
|
64
|
+
export interface OutDeps {
|
|
65
|
+
identity: () => Identity | null;
|
|
66
|
+
boxPub: () => string;
|
|
67
|
+
listed: (machineId: string) => Promise<AccountPeer | null>;
|
|
68
|
+
allowed: () => Promise<boolean | null>;
|
|
69
|
+
post: Post;
|
|
70
|
+
pin: typeof pinPeer;
|
|
71
|
+
onLinked: (name: string) => void;
|
|
72
|
+
now: () => number;
|
|
73
|
+
pollMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** POSTs JSON to the other machine's Connect name: no redirects, small answers. */
|
|
77
|
+
export const httpsPost: Post = async (hostname, path, body) => {
|
|
78
|
+
if (!peerHostAllowed(hostname))
|
|
79
|
+
throw new Error("its address isn't a Connect machine's");
|
|
80
|
+
const res = await fetch(`https://${hostname}${path}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
redirect: "error",
|
|
85
|
+
signal: AbortSignal.timeout(15_000),
|
|
86
|
+
});
|
|
87
|
+
const raw = await readCappedBytes(res, HELLO_MAX_BYTES);
|
|
88
|
+
let json: unknown = null;
|
|
89
|
+
try {
|
|
90
|
+
json = JSON.parse(raw.toString("utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
// not JSON: only the status says anything
|
|
93
|
+
}
|
|
94
|
+
return { status: res.status, json };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let box: string | null = null;
|
|
98
|
+
export const realOutDeps = (): OutDeps => ({
|
|
99
|
+
identity: () => currentIdentity(),
|
|
100
|
+
boxPub: () => (box ??= loadBoxKey().publicRaw),
|
|
101
|
+
listed: listedPeer,
|
|
102
|
+
allowed: linkingAllowed,
|
|
103
|
+
post: httpsPost,
|
|
104
|
+
pin: pinPeer,
|
|
105
|
+
onLinked: notifyLinked,
|
|
106
|
+
now: Date.now,
|
|
107
|
+
pollMs: 2000,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const g = globalThis as unknown as { __agentosPairingOut?: Map<string, Live> };
|
|
111
|
+
const links = () => (g.__agentosPairingOut ??= new Map());
|
|
112
|
+
|
|
113
|
+
export const resetOutgoing = () => {
|
|
114
|
+
for (const l of links().values()) if (l.timer) clearTimeout(l.timer);
|
|
115
|
+
g.__agentosPairingOut = undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const view = (l: Live): Outgoing => ({
|
|
119
|
+
id: l.id,
|
|
120
|
+
machineId: l.machineId,
|
|
121
|
+
name: l.name,
|
|
122
|
+
code: l.code,
|
|
123
|
+
state: l.state,
|
|
124
|
+
error: l.error,
|
|
125
|
+
hostId: l.hostId,
|
|
126
|
+
attemptsLeft: l.attemptsLeft,
|
|
127
|
+
startedAt: l.startedAt,
|
|
128
|
+
expiresAt: l.expiresAt,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export class LinkError extends Error {
|
|
132
|
+
constructor(
|
|
133
|
+
message: string,
|
|
134
|
+
readonly status = 400
|
|
135
|
+
) {
|
|
136
|
+
super(message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const theirError = (json: unknown, status: number) => {
|
|
141
|
+
const e = (json as { error?: unknown } | null)?.error;
|
|
142
|
+
return typeof e === "string"
|
|
143
|
+
? e.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200)
|
|
144
|
+
: `HTTP ${status}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Starts a link to an account machine; the answer carries the code to show. */
|
|
148
|
+
export async function startLink(
|
|
149
|
+
machineId: string,
|
|
150
|
+
deps: OutDeps = realOutDeps()
|
|
151
|
+
): Promise<Outgoing> {
|
|
152
|
+
const self = deps.identity();
|
|
153
|
+
if (!self)
|
|
154
|
+
throw new LinkError(
|
|
155
|
+
"This machine isn't connected to AgentOS Connect.",
|
|
156
|
+
409
|
|
157
|
+
);
|
|
158
|
+
if ((await deps.allowed()) !== true)
|
|
159
|
+
throw new LinkError("Linking machines needs AgentOS Connect Pro.", 402);
|
|
160
|
+
const peer = await deps.listed(machineId);
|
|
161
|
+
if (!peer)
|
|
162
|
+
throw new LinkError("That machine isn't on your AgentOS account.", 404);
|
|
163
|
+
if (!peerHostAllowed(peer.hostname))
|
|
164
|
+
throw new LinkError("Its address isn't a Connect machine's.", 502);
|
|
165
|
+
if (keysChanged(peer.id, peer)) throw new LinkError(KEY_CHANGED, 409);
|
|
166
|
+
for (const l of links().values())
|
|
167
|
+
if (l.machineId === machineId && open(l))
|
|
168
|
+
stop(l, "declined", "replaced by a new request");
|
|
169
|
+
|
|
170
|
+
const aNonce = randomNonce(32);
|
|
171
|
+
const aBox = deps.boxPub();
|
|
172
|
+
const hello = signHello(self.signKey, {
|
|
173
|
+
from: self.machineId,
|
|
174
|
+
to: peer.id,
|
|
175
|
+
ts: deps.now(),
|
|
176
|
+
nonce: randomNonce(16),
|
|
177
|
+
sign_pub: self.signPub,
|
|
178
|
+
box_pub: aBox,
|
|
179
|
+
commit: commitTo(aBox, self.signPub, aNonce),
|
|
180
|
+
});
|
|
181
|
+
const send = async (path: string, body: object) => {
|
|
182
|
+
try {
|
|
183
|
+
return await deps.post(peer.hostname, path, body);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new LinkError(
|
|
186
|
+
`Can't reach ${peer.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
187
|
+
502
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const helloRes = await send("/api/peer/hello", hello);
|
|
192
|
+
if (helloRes.status !== 200)
|
|
193
|
+
throw new LinkError(
|
|
194
|
+
`${peer.name} said no: ${theirError(helloRes.json, helloRes.status)}`,
|
|
195
|
+
helloRes.status === 402 ? 402 : 502
|
|
196
|
+
);
|
|
197
|
+
const ack = parseAck(helloRes.json);
|
|
198
|
+
// Signed by the key the account lists for it, answering this hello.
|
|
199
|
+
if (!ack || !verifyAck(ack, hello, peer.signPub))
|
|
200
|
+
throw new LinkError(`${peer.name} answered with the wrong key.`, 502);
|
|
201
|
+
if (keysChanged(peer.id, { signPub: ack.sign_pub, boxPub: ack.box_pub }))
|
|
202
|
+
throw new LinkError(KEY_CHANGED, 409);
|
|
203
|
+
|
|
204
|
+
const reveal = signReveal(self.signKey, {
|
|
205
|
+
from: self.machineId,
|
|
206
|
+
to: peer.id,
|
|
207
|
+
ts: deps.now(),
|
|
208
|
+
nonce: randomNonce(16),
|
|
209
|
+
request_id: ack.request_id,
|
|
210
|
+
a_nonce: aNonce,
|
|
211
|
+
});
|
|
212
|
+
const revealRes = await send("/api/peer/hello/reveal", reveal);
|
|
213
|
+
const answer = parseAnswer(revealRes.json);
|
|
214
|
+
if (
|
|
215
|
+
revealRes.status !== 200 ||
|
|
216
|
+
!answer ||
|
|
217
|
+
!verifyAnswer(answer, reveal, peer.signPub) ||
|
|
218
|
+
answer.state !== "pending"
|
|
219
|
+
)
|
|
220
|
+
throw new LinkError(
|
|
221
|
+
`${peer.name} didn't take the request: ${theirError(revealRes.json, revealRes.status)}`,
|
|
222
|
+
502
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const now = deps.now();
|
|
226
|
+
const transcript = {
|
|
227
|
+
from: self.machineId,
|
|
228
|
+
to: peer.id,
|
|
229
|
+
aSign: self.signPub,
|
|
230
|
+
aBox,
|
|
231
|
+
bSign: ack.sign_pub,
|
|
232
|
+
bBox: ack.box_pub,
|
|
233
|
+
aNonce,
|
|
234
|
+
bNonce: ack.b_nonce,
|
|
235
|
+
};
|
|
236
|
+
const live: Live = {
|
|
237
|
+
id: randomNonce(16),
|
|
238
|
+
machineId: peer.id,
|
|
239
|
+
name: peer.name,
|
|
240
|
+
code: deriveCode(transcript),
|
|
241
|
+
state: "pending",
|
|
242
|
+
error: null,
|
|
243
|
+
hostId: null,
|
|
244
|
+
attemptsLeft: MAX_WRONG,
|
|
245
|
+
startedAt: now,
|
|
246
|
+
expiresAt: now + REQUEST_TTL_MS,
|
|
247
|
+
peer,
|
|
248
|
+
requestId: ack.request_id,
|
|
249
|
+
bBox: ack.box_pub,
|
|
250
|
+
confirmCode: deriveCode(transcript, "confirm"),
|
|
251
|
+
timer: null,
|
|
252
|
+
};
|
|
253
|
+
links().set(live.id, live);
|
|
254
|
+
schedule(live, self, deps);
|
|
255
|
+
return view(live);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const open = (l: Live) => l.state === "pending" || l.state === "confirm";
|
|
259
|
+
|
|
260
|
+
function stop(l: Live, state: OutState, error: string | null = null) {
|
|
261
|
+
if (l.timer) clearTimeout(l.timer);
|
|
262
|
+
l.timer = null;
|
|
263
|
+
l.state = state;
|
|
264
|
+
l.error = error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function schedule(l: Live, self: Identity, deps: OutDeps) {
|
|
268
|
+
l.timer = setTimeout(() => void poll(l, self, deps), deps.pollMs);
|
|
269
|
+
l.timer.unref?.();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One status call; exported for tests that drive the clock. */
|
|
273
|
+
export async function poll(l: Live, self: Identity, deps: OutDeps) {
|
|
274
|
+
l.timer = null;
|
|
275
|
+
if (l.state !== "pending") return;
|
|
276
|
+
if (deps.now() > l.expiresAt) return stop(l, "expired");
|
|
277
|
+
const ask = signStatusAsk(self.signKey, {
|
|
278
|
+
from: self.machineId,
|
|
279
|
+
to: l.machineId,
|
|
280
|
+
ts: deps.now(),
|
|
281
|
+
nonce: randomNonce(16),
|
|
282
|
+
request_id: l.requestId,
|
|
283
|
+
});
|
|
284
|
+
let answer = null;
|
|
285
|
+
try {
|
|
286
|
+
const res = await deps.post(l.peer.hostname, "/api/peer/hello/status", ask);
|
|
287
|
+
if (res.status === 404) return stop(l, "expired");
|
|
288
|
+
const a = parseAnswer(res.json);
|
|
289
|
+
if (res.status === 200 && a && verifyAnswer(a, ask, l.peer.signPub))
|
|
290
|
+
answer = a;
|
|
291
|
+
else l.error = `unexpected answer (HTTP ${res.status})`;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Offline for a moment: keep asking until the request expires.
|
|
294
|
+
l.error = err instanceof Error ? err.message : String(err);
|
|
295
|
+
}
|
|
296
|
+
if (l.state !== "pending") return;
|
|
297
|
+
if (answer?.state === "accepted") {
|
|
298
|
+
// Nothing is pinned yet: B's say-so is only as good as its key, and the
|
|
299
|
+
// key came from the account list. The person types B's code here next.
|
|
300
|
+
stop(l, "confirm");
|
|
301
|
+
l.expiresAt = deps.now() + REQUEST_TTL_MS;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (answer && answer.state !== "pending") return stop(l, answer.state);
|
|
305
|
+
if (answer) l.error = null;
|
|
306
|
+
schedule(l, self, deps);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function outgoingLink(id: string): Outgoing | null {
|
|
310
|
+
const l = links().get(id);
|
|
311
|
+
return l ? view(l) : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type ConfirmResult =
|
|
315
|
+
| { ok: true; link: Outgoing }
|
|
316
|
+
| { ok: false; status: number; error: string; attemptsLeft?: number };
|
|
317
|
+
|
|
318
|
+
/** The code the other machine shows once it took ours, typed here. */
|
|
319
|
+
export function confirmLink(
|
|
320
|
+
id: string,
|
|
321
|
+
input: unknown,
|
|
322
|
+
deps: OutDeps = realOutDeps()
|
|
323
|
+
): ConfirmResult {
|
|
324
|
+
const l = links().get(id);
|
|
325
|
+
if (l?.state === "confirm" && deps.now() > l.expiresAt) stop(l, "expired");
|
|
326
|
+
if (!l || l.state !== "confirm")
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
status: 404,
|
|
330
|
+
error: "That request has ended. Start again.",
|
|
331
|
+
};
|
|
332
|
+
const code = typedCode(input);
|
|
333
|
+
if (!code)
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
status: 400,
|
|
337
|
+
error: `Type the 6 digits ${l.name} shows.`,
|
|
338
|
+
};
|
|
339
|
+
if (!sameCode(code, l.confirmCode)) {
|
|
340
|
+
l.attemptsLeft -= 1;
|
|
341
|
+
if (l.attemptsLeft <= 0) {
|
|
342
|
+
stop(l, "failed", "wrong code 3 times");
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
status: 403,
|
|
346
|
+
error: "Wrong code 3 times: nothing was linked here. Start again.",
|
|
347
|
+
attemptsLeft: 0,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: 403,
|
|
353
|
+
error: "That code doesn't match.",
|
|
354
|
+
attemptsLeft: l.attemptsLeft,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
l.hostId = deps.pin({
|
|
359
|
+
id: l.peer.id,
|
|
360
|
+
name: l.peer.name,
|
|
361
|
+
hostname: l.peer.hostname,
|
|
362
|
+
signPub: l.peer.signPub,
|
|
363
|
+
boxPub: l.bBox,
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
stop(l, "failed", err instanceof Error ? err.message : String(err));
|
|
367
|
+
return { ok: false, status: 409, error: l.error! };
|
|
368
|
+
}
|
|
369
|
+
stop(l, "accepted");
|
|
370
|
+
deps.onLinked(l.name);
|
|
371
|
+
return { ok: true, link: view(l) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function cancelLink(id: string): boolean {
|
|
375
|
+
const l = links().get(id);
|
|
376
|
+
if (!l || !open(l)) return false;
|
|
377
|
+
stop(l, "declined", "cancelled here");
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tests: the live record behind a view, to drive poll() by hand. */
|
|
382
|
+
export const _live = (id: string) => links().get(id) ?? null;
|