agent-dag 3.15.0 → 3.16.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/dist/web/assets/index-C7_uf54q.js +254 -0
- package/dist/web/assets/{index-CqAgutJT.css → index-DPOZaaRr.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/deck-prefs.mjs +33 -14
- package/src/server/index.mjs +107 -24
- package/src/server/lan-engine.mjs +374 -66
- package/src/server/lan-socket.mjs +245 -63
- package/src/server/lan-sync.mjs +321 -86
- package/dist/web/assets/index-CbYzt04Y.js +0 -248
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-C7_uf54q.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DPOZaaRr.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.0",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -336,5 +336,15 @@
|
|
|
336
336
|
"title": "🔕 The deck no longer puts anything in your settings.json",
|
|
337
337
|
"body": "It used to add a hook there so Claude Code would play a sound when a turn ended. That entry is removed the first time you start this version, and any sound hook you wrote yourself is put back exactly where you had it. Nothing else in that file is touched. The deck makes the sound itself now — which is also why you finally hear one when a Codex turn ends, not just a Claude one."
|
|
338
338
|
}
|
|
339
|
+
],
|
|
340
|
+
"3.16.0": [
|
|
341
|
+
{
|
|
342
|
+
"title": "🤝 Pair two decks with one piece of text",
|
|
343
|
+
"body": "**Accounts ▸ Local network ▸ setup… ▸ make an invite.** Send the text however you already talk to them; they paste it and the two decks are paired. Nobody presses accept, and neither of you has to know which of your addresses the other can reach — the invite carries them all and their deck tries each one.\n\nDecks on the same router still find each other on their own and appear under **on this network right now**. An invite is for the case that does not work: another subnet, a VPN, a firewall.\n\nThe invite lasts 10 minutes and is used once. Anyone who gets hold of the text can pair with your deck until it runs out, so send it the way you would send anything else you would not want forwarded."
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"title": "⚠️ Both machines have to be on this version",
|
|
347
|
+
"body": "The two decks no longer speak the old protocol, and there is no fallback: a 3.15 deck and a 3.16 deck cannot see each other at all. Update both.\n\nThe group passphrase is gone. If you had one set, that pairing is not carried over — make an invite and pair again. It was replaced because a passphrase that differs by one character produced a closed socket and no other symptom on either machine, and a secret is the one thing a panel must never print, so neither person could check theirs against the other's."
|
|
348
|
+
}
|
|
339
349
|
]
|
|
340
350
|
}
|
|
@@ -54,7 +54,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
54
54
|
notifications: true,
|
|
55
55
|
// LAN sync, off until somebody turns it on. `passphrase` is the only secret
|
|
56
56
|
// this file has ever held, which is why the write below now names a mode.
|
|
57
|
-
lan: Object.freeze({ enabled: false, name: "",
|
|
57
|
+
lan: Object.freeze({ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
/** The mode prefs.json is created with.
|
|
@@ -79,15 +79,29 @@ function normaliseLan(raw) {
|
|
|
79
79
|
return {
|
|
80
80
|
enabled: typeof src.enabled === "boolean" ? src.enabled : false,
|
|
81
81
|
name: typeof src.name === "string" ? src.name : "",
|
|
82
|
-
|
|
82
|
+
// THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
|
|
83
|
+
// which is why the write below names a mode rather than taking the umask's.
|
|
84
|
+
// It is an X25519 private key in base64 pkcs8; anything else is replaced on
|
|
85
|
+
// the next start rather than refused, because a corrupt key is not a thing
|
|
86
|
+
// anybody can act on and refusing to start would take the feature away.
|
|
87
|
+
secret: typeof src.secret === "string" ? src.secret : "",
|
|
83
88
|
shared: strings(src.shared),
|
|
84
89
|
manual: strings(src.manual),
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
// THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
|
|
91
|
+
// half: a fingerprint is a hash of it, so an entry without one cannot be
|
|
92
|
+
// checked against whatever answers at an address later, and an entry that
|
|
93
|
+
// cannot be checked is worse than no entry at all.
|
|
94
|
+
trusted: (Array.isArray(src.trusted) ? src.trusted : [])
|
|
95
|
+
.filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
|
|
96
|
+
.map(t => ({ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "" })),
|
|
97
|
+
// The port this deck listened on last time, so an address somebody typed on
|
|
98
|
+
// the other machine still works after a restart. It asked the OS for a new
|
|
99
|
+
// one every start, which is invisible while broadcast works and is exactly
|
|
100
|
+
// what does not work in the case the address field exists for.
|
|
101
|
+
//
|
|
102
|
+
// A PREFERENCE, never a requirement: a port already taken falls through to
|
|
103
|
+
// an OS-chosen one and this is rewritten. 0 means "none yet".
|
|
104
|
+
port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
|
|
91
105
|
};
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -168,18 +182,23 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
|
168
182
|
/**
|
|
169
183
|
* The preferences as a PAGE may see them.
|
|
170
184
|
*
|
|
171
|
-
*
|
|
185
|
+
* THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
|
|
172
186
|
* anything that can reach the loopback port — which is the whole point of the
|
|
173
187
|
* deck's own threat model, and is why the share envelope is not served there
|
|
174
|
-
* either
|
|
188
|
+
* either. A page has no use for the key: what it needs is this deck's
|
|
189
|
+
* fingerprint, which is a hash of the PUBLIC half and comes from `/api/lan`.
|
|
175
190
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
191
|
+
* The pinned public keys of trusted decks go the same way, for a smaller
|
|
192
|
+
* reason: they are not secret, and they are forty characters of base64 that no
|
|
193
|
+
* page draws. What a page shows is a name and a fingerprint.
|
|
178
194
|
*/
|
|
179
195
|
export function publicPrefs(prefs) {
|
|
180
196
|
const p = normalise(prefs);
|
|
181
|
-
const {
|
|
182
|
-
return {
|
|
197
|
+
const { secret, trusted, ...lan } = p.lan;
|
|
198
|
+
return {
|
|
199
|
+
...p,
|
|
200
|
+
lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
|
|
201
|
+
};
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
export function notificationsOn(prefs, env = process.env) {
|
package/src/server/index.mjs
CHANGED
|
@@ -18,7 +18,6 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
18
18
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
19
|
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
20
20
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
21
|
-
import { suggestPassphrase } from "./lan-sync.mjs";
|
|
22
21
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
23
22
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
24
23
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3255,12 +3254,37 @@ const lanEngine = createEngine({
|
|
|
3255
3254
|
const out = await importAccount(blob);
|
|
3256
3255
|
return !!out?.ok;
|
|
3257
3256
|
},
|
|
3258
|
-
// The deck's own
|
|
3259
|
-
//
|
|
3260
|
-
// none
|
|
3261
|
-
|
|
3257
|
+
// The deck's own long-term key, kept so a restart is the same deck rather
|
|
3258
|
+
// than a stranger to everybody who has paired with it. Written once, on the
|
|
3259
|
+
// first start that has none. It IS a secret — prefs.json is 0600 for this.
|
|
3260
|
+
// The port it actually got, kept so an address typed on the other machine
|
|
3261
|
+
// still reaches this deck after it restarts. Written only when it differs
|
|
3262
|
+
// from what is stored, so a normal start writes nothing.
|
|
3263
|
+
onPort: async port => {
|
|
3264
|
+
try { _prefs = await writePrefs({ lan: { port } }); }
|
|
3265
|
+
catch { /* the address field still works this session; next start re-pins */ }
|
|
3266
|
+
},
|
|
3267
|
+
// A deck was accepted or unpaired. Written straight through, because the
|
|
3268
|
+
// trusted list is the whole of who this deck will talk to and a list that
|
|
3269
|
+
// only existed in memory would drop every pairing on restart.
|
|
3270
|
+
onTrust: async trusted => {
|
|
3271
|
+
try { _prefs = await writePrefs({ lan: { trusted } }); }
|
|
3272
|
+
catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
|
|
3273
|
+
},
|
|
3274
|
+
// An address this deck must keep dialling: the far end of a pairing that
|
|
3275
|
+
// happened over an invite. Kept in prefs, because setPeers replaces the dial
|
|
3276
|
+
// list wholesale on every settings write and a row that lives only in memory
|
|
3277
|
+
// is a pairing that goes one-way at the next restart.
|
|
3278
|
+
onDial: async entry => {
|
|
3279
|
+
try {
|
|
3280
|
+
const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
|
|
3281
|
+
if (manual.includes(entry)) return;
|
|
3282
|
+
_prefs = await writePrefs({ lan: { manual: [...manual, entry] } });
|
|
3283
|
+
} catch { /* dialled this session; the next round re-adds it */ }
|
|
3284
|
+
},
|
|
3285
|
+
onIdentity: async secret => {
|
|
3262
3286
|
try {
|
|
3263
|
-
_prefs = await writePrefs({ lan: {
|
|
3287
|
+
_prefs = await writePrefs({ lan: { secret } });
|
|
3264
3288
|
// AND PUT IT TO WORK. Writing it alone was not enough: a clash was
|
|
3265
3289
|
// detected, a new id was stored, and both decks kept broadcasting the old
|
|
3266
3290
|
// one — so they stayed invisible to each other with a correct file on
|
|
@@ -3285,9 +3309,10 @@ async function applyLanPrefs() {
|
|
|
3285
3309
|
await lanEngine.apply({
|
|
3286
3310
|
enabled: !!lan.enabled,
|
|
3287
3311
|
name: lan.name || defaultName(),
|
|
3288
|
-
|
|
3312
|
+
secret: lan.secret || "",
|
|
3289
3313
|
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
3290
|
-
|
|
3314
|
+
trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
|
|
3315
|
+
port: lan.port || 0,
|
|
3291
3316
|
});
|
|
3292
3317
|
// Wholesale, so removing an address in the panel really stops it being
|
|
3293
3318
|
// dialled rather than only taking the row away.
|
|
@@ -3304,22 +3329,79 @@ function handleLanStatus(req, res) {
|
|
|
3304
3329
|
}
|
|
3305
3330
|
|
|
3306
3331
|
/**
|
|
3307
|
-
*
|
|
3308
|
-
*
|
|
3309
|
-
*
|
|
3310
|
-
*
|
|
3311
|
-
*
|
|
3312
|
-
*
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
*
|
|
3316
|
-
*
|
|
3317
|
-
*
|
|
3318
|
-
*
|
|
3319
|
-
*
|
|
3332
|
+
* Accept a deck, dismiss a request, or unpair one.
|
|
3333
|
+
*
|
|
3334
|
+
* ONE ROUTE, THREE VERBS, in the shape the accounts panel already uses for its
|
|
3335
|
+
* own multi-verb writes. Each one takes a fingerprint and nothing else: the key
|
|
3336
|
+
* being pinned comes from what this deck actually saw on the wire, never from
|
|
3337
|
+
* the page, so a page cannot pair this deck with a key nobody has met.
|
|
3338
|
+
*/
|
|
3339
|
+
/**
|
|
3340
|
+
* Make an invite, put one away, or join on somebody else's.
|
|
3341
|
+
*
|
|
3342
|
+
* The token is the secret and it is never stored: minted on request, held in
|
|
3343
|
+
* memory until it expires or is used, and gone with the process. Nothing about
|
|
3344
|
+
* it reaches prefs.json, so a deck that restarts is offering nothing rather
|
|
3345
|
+
* than offering something its owner has forgotten they sent.
|
|
3320
3346
|
*/
|
|
3321
|
-
function
|
|
3322
|
-
|
|
3347
|
+
async function handleLanInvite(req, res) {
|
|
3348
|
+
const raw = await readBody(req).catch(() => null);
|
|
3349
|
+
let body = null;
|
|
3350
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3351
|
+
switch (body?.action) {
|
|
3352
|
+
case "make": {
|
|
3353
|
+
const made = lanEngine.invite();
|
|
3354
|
+
if (!made) return send(res, 409, { ok: false, reason: "not_running" });
|
|
3355
|
+
// AFTER the spread, not before: status carries its own `invite` — the
|
|
3356
|
+
// token and its expiry, which is all a panel needs — and letting that
|
|
3357
|
+
// overwrite this one dropped the address list the caller asked for.
|
|
3358
|
+
return send(res, 200, { ok: true, ...lanEngine.status(), invite: made });
|
|
3359
|
+
}
|
|
3360
|
+
case "withdraw":
|
|
3361
|
+
lanEngine.withdraw();
|
|
3362
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3363
|
+
case "join": {
|
|
3364
|
+
const out = await lanEngine.join(body.token);
|
|
3365
|
+
if (!out.ok) return send(res, 200, { ok: false, ...out, ...lanEngine.status() });
|
|
3366
|
+
return send(res, 200, { ok: true, ...out, ...lanEngine.status() });
|
|
3367
|
+
}
|
|
3368
|
+
default:
|
|
3369
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
async function handleLanPeer(req, res) {
|
|
3374
|
+
const raw = await readBody(req).catch(() => null);
|
|
3375
|
+
let body = null;
|
|
3376
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3377
|
+
const fp = body && typeof body.fp === "string" ? body.fp : null;
|
|
3378
|
+
if (!fp) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3379
|
+
switch (body.action) {
|
|
3380
|
+
case "accept": {
|
|
3381
|
+
const added = lanEngine.accept(fp);
|
|
3382
|
+
if (!added) return send(res, 409, { ok: false, reason: "not_seen" });
|
|
3383
|
+
// A deck we only HEARD is not pinned, it is dialled — see accept. The
|
|
3384
|
+
// address has to reach prefs or the next write of the settings wipes it:
|
|
3385
|
+
// setPeers replaces the dial list wholesale, on purpose, so an address
|
|
3386
|
+
// that lives only in memory disappears the first time anything else is
|
|
3387
|
+
// saved.
|
|
3388
|
+
if (added.dialled) {
|
|
3389
|
+
const entry = `${added.addr}:${added.port}`;
|
|
3390
|
+
const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
|
|
3391
|
+
if (!manual.includes(entry)) {
|
|
3392
|
+
try { _prefs = await writePrefs({ lan: { manual: [...manual, entry] } }); }
|
|
3393
|
+
catch { /* it is dialled this session; the next accept re-adds it */ }
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
return send(res, 200, { ok: true, added, ...lanEngine.status() });
|
|
3397
|
+
}
|
|
3398
|
+
case "dismiss":
|
|
3399
|
+
return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
|
|
3400
|
+
case "unpair":
|
|
3401
|
+
return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
|
|
3402
|
+
default:
|
|
3403
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
3404
|
+
}
|
|
3323
3405
|
}
|
|
3324
3406
|
|
|
3325
3407
|
/** Ask every peer now rather than at the next tick — the button beside the
|
|
@@ -5558,7 +5640,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5558
5640
|
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
5559
5641
|
if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
|
|
5560
5642
|
if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
|
|
5561
|
-
if (req.method === "
|
|
5643
|
+
if (req.method === "POST" && url.pathname === "/api/lan/peer") return guard(handleLanPeer(req, res), res);
|
|
5644
|
+
if (req.method === "POST" && url.pathname === "/api/lan/invite") return guard(handleLanInvite(req, res), res);
|
|
5562
5645
|
if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
|
|
5563
5646
|
if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
|
|
5564
5647
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|