agent-dag 3.14.0 → 3.15.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-CbYzt04Y.js +248 -0
- package/dist/web/assets/{index-Xyt_GsRj.css → index-CqAgutJT.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/claude-accounts.mjs +13 -0
- package/src/server/deck-prefs.mjs +68 -4
- package/src/server/index.mjs +119 -3
- package/src/server/lan-engine.mjs +359 -0
- package/src/server/lan-socket.mjs +395 -0
- package/src/server/lan-sync.mjs +620 -0
- package/dist/web/assets/index-CinWEMD2.js +0 -244
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-CbYzt04Y.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CqAgutJT.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.15.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
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.15.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "🔗 Decks on one network heal each other's dead logins",
|
|
37
|
+
"body": "A login dies on the machine that has not used it while the same account keeps working on the machine that has. The fix was a blob copied out of one deck and pasted into another. Now: **Accounts ▸ Local network**, the same passphrase on each deck — it offers you six words — and they find each other and repair themselves.\n\nA deck only **adds** an account it does not have, or **replaces one it has proven dead** — proven by Anthropic refusing the token, not guessed. A credential that works is never touched. The passphrase is the only gate; a stranger on the same wifi sees that a deck exists and nothing else."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "⚠️ What sharing a login actually means",
|
|
41
|
+
"body": "**A login you share is a live one, and it cannot be taken back.** Turning the switch off stops what has not happened yet; a credential that has left your machine is gone, and the only real revocation is signing in again at Anthropic — which ends the session everywhere at once.\n\nSharing a Claude account may also be against Anthropic's terms. Your call; the deck says it once and does not nag."
|
|
42
|
+
}
|
|
43
|
+
],
|
|
34
44
|
"3.14.0": [
|
|
35
45
|
{
|
|
36
46
|
"title": "📊 The machine comes back beside the list that covers it",
|
|
@@ -464,6 +464,19 @@ async function readRoster(now, gen) {
|
|
|
464
464
|
email: acct.email ?? null,
|
|
465
465
|
alias: acct.alias ?? null,
|
|
466
466
|
org: acct.organizationName ?? null,
|
|
467
|
+
// The other half of an account's identity, and the reason a slot number
|
|
468
|
+
// is not one: claude-swap keys on `(email, organizationUuid)` — the same
|
|
469
|
+
// email under two orgs is two accounts on purpose — and assigns slots
|
|
470
|
+
// max+1 per store, so the account that is 4 here is 2 on another machine.
|
|
471
|
+
// Surfaced for LAN sync, which has to match accounts across two stores
|
|
472
|
+
// that grew in a different order.
|
|
473
|
+
orgUuid: acct.organizationUuid ?? null,
|
|
474
|
+
// Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
|
|
475
|
+
// signed in. The two differ, and #721 is the whole argument: a
|
|
476
|
+
// `stale-copy` row means the live session is fine while the copy in the
|
|
477
|
+
// store is dead, and the copy is what a share would carry and what a
|
|
478
|
+
// peer's copy would heal. So both kinds of trouble read as not alive.
|
|
479
|
+
alive: trouble == null,
|
|
467
480
|
active: String(seq.activeAccountNumber) === num,
|
|
468
481
|
disabled: acct.disabled === true,
|
|
469
482
|
lanes,
|
|
@@ -50,7 +50,46 @@ export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "pre
|
|
|
50
50
|
* release notes describe; a switch that quietly turned an existing feature off
|
|
51
51
|
* on upgrade would be a worse surprise than the noise it is meant to stop.
|
|
52
52
|
*/
|
|
53
|
-
export const DEFAULTS = Object.freeze({
|
|
53
|
+
export const DEFAULTS = Object.freeze({
|
|
54
|
+
notifications: true,
|
|
55
|
+
// LAN sync, off until somebody turns it on. `passphrase` is the only secret
|
|
56
|
+
// this file has ever held, which is why the write below now names a mode.
|
|
57
|
+
lan: Object.freeze({ enabled: false, name: "", passphrase: "", shared: [], manual: [], deckId: "" }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The mode prefs.json is created with.
|
|
61
|
+
*
|
|
62
|
+
* It held nothing but booleans until LAN sync, and a booleans file at the
|
|
63
|
+
* umask default is unremarkable. A group passphrase is not: on a shared
|
|
64
|
+
* machine the default mode hands it to every other account on the box, and
|
|
65
|
+
* from it they can decrypt any credential that crosses the network.
|
|
66
|
+
*
|
|
67
|
+
* Passed to `writeFile` rather than applied with a follow-up chmod, and the
|
|
68
|
+
* difference is the whole point — claude-swap's transfer.py makes the same
|
|
69
|
+
* argument at length: a write-then-chmod leaves the file readable for the
|
|
70
|
+
* window between the two, which is exactly when a secret is in it. */
|
|
71
|
+
export const PREFS_MODE = 0o600;
|
|
72
|
+
|
|
73
|
+
/** One LAN section, coerced. Unknown keys dropped like everything else here,
|
|
74
|
+
* and the two lists forced to arrays of strings — they arrive from a page and
|
|
75
|
+
* are then compared against account keys and dialled as addresses. */
|
|
76
|
+
function normaliseLan(raw) {
|
|
77
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
78
|
+
const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
|
|
79
|
+
return {
|
|
80
|
+
enabled: typeof src.enabled === "boolean" ? src.enabled : false,
|
|
81
|
+
name: typeof src.name === "string" ? src.name : "",
|
|
82
|
+
passphrase: typeof src.passphrase === "string" ? src.passphrase : "",
|
|
83
|
+
shared: strings(src.shared),
|
|
84
|
+
manual: strings(src.manual),
|
|
85
|
+
// A PUBLIC identifier, not a key and not a secret: it is in every beacon
|
|
86
|
+
// this deck broadcasts, and what authenticates is the passphrase. It is
|
|
87
|
+
// persisted so a restarted deck is recognised as the same one — without
|
|
88
|
+
// that, every restart adds a row to every peer's list that will never
|
|
89
|
+
// answer again, and the list is a graveyard within an afternoon.
|
|
90
|
+
deckId: typeof src.deckId === "string" && /^[0-9a-f]{12}$/.test(src.deckId) ? src.deckId : "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
54
93
|
|
|
55
94
|
/** Coerce whatever is on disk into a whole, known-shaped prefs object.
|
|
56
95
|
*
|
|
@@ -60,7 +99,10 @@ export const DEFAULTS = Object.freeze({ notifications: true });
|
|
|
60
99
|
* a setting nothing here can see, which reads as support and is not. */
|
|
61
100
|
export function normalise(raw) {
|
|
62
101
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
63
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
|
|
104
|
+
lan: normaliseLan(src.lan),
|
|
105
|
+
};
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
/** What is on disk, or the defaults. A corrupt or absent file is not an error
|
|
@@ -97,10 +139,15 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
|
97
139
|
// 500s through `guard`, and the notifications switch silently does not
|
|
98
140
|
// stick. POSIX rename(2) has no such rule, which is why this shipped green.
|
|
99
141
|
const mv = deps.rename ?? renameWithRetry;
|
|
100
|
-
const
|
|
142
|
+
const prev = await readPrefs(home, deps);
|
|
143
|
+
// The LAN section merges rather than replaces, so a page toggling the
|
|
144
|
+
// switch does not have to send the passphrase back to keep it — and so
|
|
145
|
+
// nothing has to send a secret it was never given.
|
|
146
|
+
const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
|
|
147
|
+
const next = normalise(merged);
|
|
101
148
|
await mk(prefsDir(home), { recursive: true });
|
|
102
149
|
const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
|
|
103
|
-
await write(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
150
|
+
await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
|
|
104
151
|
await mv(tmp, prefsPath(home));
|
|
105
152
|
return next;
|
|
106
153
|
};
|
|
@@ -118,6 +165,23 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
|
118
165
|
* the person who wrote the launch script are not always the same person, and
|
|
119
166
|
* only one of them is making a claim about the machine.
|
|
120
167
|
*/
|
|
168
|
+
/**
|
|
169
|
+
* The preferences as a PAGE may see them.
|
|
170
|
+
*
|
|
171
|
+
* The passphrase never leaves this process. `GET /api/prefs` is readable by
|
|
172
|
+
* anything that can reach the loopback port — which is the whole point of the
|
|
173
|
+
* deck's own threat model, and is why the share envelope is not served there
|
|
174
|
+
* either — so what goes out is whether one is set, not what it is.
|
|
175
|
+
*
|
|
176
|
+
* A boolean rather than a mask: `••••••••` in a field invites a page to send it
|
|
177
|
+
* back, and then the dots are the passphrase.
|
|
178
|
+
*/
|
|
179
|
+
export function publicPrefs(prefs) {
|
|
180
|
+
const p = normalise(prefs);
|
|
181
|
+
const { passphrase, ...lan } = p.lan;
|
|
182
|
+
return { ...p, lan: { ...lan, hasPassphrase: passphrase !== "" } };
|
|
183
|
+
}
|
|
184
|
+
|
|
121
185
|
export function notificationsOn(prefs, env = process.env) {
|
|
122
186
|
if (env[OFF_ENV] === "1") return false;
|
|
123
187
|
return normalise(prefs).notifications;
|
package/src/server/index.mjs
CHANGED
|
@@ -16,7 +16,9 @@ import { claudeConfigDir } from "./claude-dir.mjs";
|
|
|
16
16
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
17
17
|
import { PRODUCT } from "./brand.mjs";
|
|
18
18
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
|
-
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
19
|
+
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
20
|
+
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
21
|
+
import { suggestPassphrase } from "./lan-sync.mjs";
|
|
20
22
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
21
23
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
22
24
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3169,7 +3171,10 @@ function redactDeckToken(raw) {
|
|
|
3169
3171
|
* same freshness every other cross-deck setting has.
|
|
3170
3172
|
*/
|
|
3171
3173
|
let _prefs = { ...PREF_DEFAULTS };
|
|
3172
|
-
|
|
3174
|
+
// And LAN sync starts from the same read, so a deck that had it on comes back
|
|
3175
|
+
// with it on. `applyLanPrefs` is defined below — this runs after module
|
|
3176
|
+
// evaluation, which is what makes the forward reference fine.
|
|
3177
|
+
readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
|
|
3173
3178
|
|
|
3174
3179
|
const blockNotifier = createBlockNotifier({
|
|
3175
3180
|
notify: osNotify,
|
|
@@ -3197,7 +3202,11 @@ const blockNotifier = createBlockNotifier({
|
|
|
3197
3202
|
function prefsPayload() {
|
|
3198
3203
|
return {
|
|
3199
3204
|
ok: true,
|
|
3200
|
-
|
|
3205
|
+
// `publicPrefs`, never `_prefs`. This route answers anything that can reach
|
|
3206
|
+
// the loopback port, and the LAN group passphrase is in the stored object —
|
|
3207
|
+
// one field, and sending it here would put it in front of every page on the
|
|
3208
|
+
// machine. What goes out is whether one is set.
|
|
3209
|
+
prefs: publicPrefs(_prefs),
|
|
3201
3210
|
notificationsAllowed: notificationsOn(_prefs),
|
|
3202
3211
|
notificationsVetoed: notificationsVetoed(),
|
|
3203
3212
|
};
|
|
@@ -3214,9 +3223,113 @@ async function handlePrefsWrite(req, res) {
|
|
|
3214
3223
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3215
3224
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3216
3225
|
_prefs = await writePrefs(body);
|
|
3226
|
+
// The engine reads its settings from here rather than holding its own copy,
|
|
3227
|
+
// so turning the switch off in the panel really does stop the sockets rather
|
|
3228
|
+
// than only changing what the panel says.
|
|
3229
|
+
await applyLanPrefs();
|
|
3217
3230
|
return send(res, 200, prefsPayload());
|
|
3218
3231
|
}
|
|
3219
3232
|
|
|
3233
|
+
// ── LAN sync ────────────────────────────────────────────────────────────────
|
|
3234
|
+
//
|
|
3235
|
+
// The engine is built once and told the settings; it opens and closes its own
|
|
3236
|
+
// sockets as those change. Nothing here touches a credential — see
|
|
3237
|
+
// lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
|
|
3238
|
+
const lanEngine = createEngine({
|
|
3239
|
+
readAccounts: async () => {
|
|
3240
|
+
const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
|
|
3241
|
+
return fetchClaudeAccounts();
|
|
3242
|
+
},
|
|
3243
|
+
exportAccount: async num => {
|
|
3244
|
+
const { shareAccounts } = await import("./cswap-admin.mjs");
|
|
3245
|
+
const out = await shareAccounts([String(num)]);
|
|
3246
|
+
return out?.ok ? out.blob : null;
|
|
3247
|
+
},
|
|
3248
|
+
importAccount: async blob => {
|
|
3249
|
+
const { importAccount } = await import("./cswap-admin.mjs");
|
|
3250
|
+
// NO `force`, ever, and this is the line where that promise is kept. A
|
|
3251
|
+
// plain import adds an account that is missing and replaces exactly one
|
|
3252
|
+
// claude-swap has quarantined; it SKIPS one that is present and healthy.
|
|
3253
|
+
// So a peer cannot overwrite a working credential of this deck's even by
|
|
3254
|
+
// lying about its own, because the flag that would allow it is not passed.
|
|
3255
|
+
const out = await importAccount(blob);
|
|
3256
|
+
return !!out?.ok;
|
|
3257
|
+
},
|
|
3258
|
+
// The deck's own public id, kept so a restart is the same deck rather than a
|
|
3259
|
+
// new row in every peer's list. Written once, on the first start that has
|
|
3260
|
+
// none — not a secret, and in every beacon this deck sends.
|
|
3261
|
+
onIdentity: async id => {
|
|
3262
|
+
try {
|
|
3263
|
+
_prefs = await writePrefs({ lan: { deckId: id } });
|
|
3264
|
+
// AND PUT IT TO WORK. Writing it alone was not enough: a clash was
|
|
3265
|
+
// detected, a new id was stored, and both decks kept broadcasting the old
|
|
3266
|
+
// one — so they stayed invisible to each other with a correct file on
|
|
3267
|
+
// disk. `apply` restarts only when the id it is holding differs from the
|
|
3268
|
+
// one it is given, so this settles after one pass rather than looping.
|
|
3269
|
+
await applyLanPrefs();
|
|
3270
|
+
} catch { /* the next start picks it up; a shared id is the cost until then */ }
|
|
3271
|
+
},
|
|
3272
|
+
onError: (what, err) => {
|
|
3273
|
+
// Reported, never thrown. A machine with no route, a firewall that refuses
|
|
3274
|
+
// the bind, an interface that comes and goes with a VPN — none of them is a
|
|
3275
|
+
// reason for the deck to fall over.
|
|
3276
|
+
if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
|
|
3277
|
+
},
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
/** Push whatever is in prefs at the engine. Called at boot and after every
|
|
3281
|
+
* write, so there is one source of truth and it is the file. */
|
|
3282
|
+
async function applyLanPrefs() {
|
|
3283
|
+
const lan = _prefs?.lan ?? {};
|
|
3284
|
+
try {
|
|
3285
|
+
await lanEngine.apply({
|
|
3286
|
+
enabled: !!lan.enabled,
|
|
3287
|
+
name: lan.name || defaultName(),
|
|
3288
|
+
passphrase: lan.passphrase || "",
|
|
3289
|
+
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
3290
|
+
deckId: lan.deckId || "",
|
|
3291
|
+
});
|
|
3292
|
+
// Wholesale, so removing an address in the panel really stops it being
|
|
3293
|
+
// dialled rather than only taking the row away.
|
|
3294
|
+
lanEngine.setPeers(lan.manual);
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/** What the panel draws: the switch, this deck's own name and address, and who
|
|
3301
|
+
* else is in the group. No passphrase, for the reason prefsPayload gives. */
|
|
3302
|
+
function handleLanStatus(req, res) {
|
|
3303
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/**
|
|
3307
|
+
* A strong group passphrase, for the field to open with.
|
|
3308
|
+
*
|
|
3309
|
+
* THE WHOLE FEATURE'S SECURITY IS THIS STRING — groupKey scrypts it and
|
|
3310
|
+
* everything else hangs off that — and until this route existed the field
|
|
3311
|
+
* opened empty under a placeholder reading "the same words on every deck",
|
|
3312
|
+
* which is an invitation to type something two people can both remember.
|
|
3313
|
+
* suggestPassphrase has been written, documented and tested since the first
|
|
3314
|
+
* commit of the feature and was reachable from nothing.
|
|
3315
|
+
*
|
|
3316
|
+
* Generated per request rather than folded into the status route, because the
|
|
3317
|
+
* status route is polled every five seconds and a suggestion that changed
|
|
3318
|
+
* under somebody's fingers while they read it out loud would be worse than no
|
|
3319
|
+
* suggestion at all.
|
|
3320
|
+
*/
|
|
3321
|
+
function handleLanSuggest(req, res) {
|
|
3322
|
+
return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** Ask every peer now rather than at the next tick — the button beside the
|
|
3326
|
+
* list, for somebody who has just fixed a login on the other machine and does
|
|
3327
|
+
* not want to wait a minute to see it arrive. */
|
|
3328
|
+
async function handleLanSync(req, res) {
|
|
3329
|
+
const done = await lanEngine.round();
|
|
3330
|
+
return send(res, 200, { ok: true, done, ...lanEngine.status() });
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3220
3333
|
function pushEvent(raw, source, opts = {}) {
|
|
3221
3334
|
// First, before anything below can see it: the deck's own credential does not
|
|
3222
3335
|
// belong in a store that is served without one. Every entry point to the
|
|
@@ -5444,6 +5557,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5444
5557
|
// Machine state, not session state: sampled on the server's own timer and
|
|
5445
5558
|
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
5446
5559
|
if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
|
|
5560
|
+
if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
|
|
5561
|
+
if (req.method === "GET" && url.pathname === "/api/lan/passphrase") return handleLanSuggest(req, res);
|
|
5562
|
+
if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
|
|
5447
5563
|
if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
|
|
5448
5564
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
5449
5565
|
// On demand only — the process list costs a subprocess on every platform,
|