ccdeck 3.32.1 → 3.33.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/README.md +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-B3UjvY82.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-OLDoLpOW.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-ohP7oQCN.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-P2zWn3Hw.js +0 -299
|
@@ -20,7 +20,7 @@ import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
|
|
|
20
20
|
// Where installers leave claude-swap and which one owns this machine's copy —
|
|
21
21
|
// pure layout, with the platform a parameter, so it lives on its own.
|
|
22
22
|
import { PKG, cswapCandidates, cswapOwner } from "./cswap-layout.mjs";
|
|
23
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
|
|
@@ -92,6 +92,98 @@ function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
|
|
|
92
92
|
|
|
93
93
|
let _bin = null;
|
|
94
94
|
|
|
95
|
+
// ── a copy this deck refused ─────────────────────────────────────────────────
|
|
96
|
+
//
|
|
97
|
+
// #1799. installAndConfirm refuses a claude-swap whose version is not the one it
|
|
98
|
+
// asked for, and the refusal used to exist only in the value it returned. The
|
|
99
|
+
// copy was still on disk, cswapBin memoized it the moment it answered
|
|
100
|
+
// `--version`, and everything after the boot row drove it anyway: the roster
|
|
101
|
+
// called it installed-and-empty and offered Add account, `/api/cswap-auto` ran
|
|
102
|
+
// `cswap config` with it, and the NEXT launch could not tell it from a copy the
|
|
103
|
+
// user installed themselves — which ensureCswap deliberately accepts whatever
|
|
104
|
+
// its number — so it read "accounts panel enabled" and was seeded with
|
|
105
|
+
// `cswap add`.
|
|
106
|
+
//
|
|
107
|
+
// So a refusal is written down, next to the update marker, and kept until
|
|
108
|
+
// something changes: while a copy answering the refused version is what
|
|
109
|
+
// resolves, cswapBin hands out REFUSED_BIN instead of it and cswapVersion
|
|
110
|
+
// answers null. It is matched on the VERSION, not on the spelling: the same
|
|
111
|
+
// file answers as `cswap` once ~/.local/bin is on PATH and by its absolute path
|
|
112
|
+
// before, and telling those apart would need a PATH walk to be right in both
|
|
113
|
+
// directions. A copy answering any other version clears the record — that is a
|
|
114
|
+
// different copy, most likely one the user installed, and it is left alone the
|
|
115
|
+
// way any other is — and so does an install that lands an acceptable one.
|
|
116
|
+
// AGENTS_DECK_CSWAP wins over all of it, as it wins over every other lookup
|
|
117
|
+
// here: someone who names a binary has chosen it.
|
|
118
|
+
const REFUSED_RECORD = join(homedir(), ".agents-deck", "cswap-refused.json");
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What cswapBin answers while the refusal holds: a path under the deck's own
|
|
122
|
+
* state directory that nothing creates, with an extension so that Windows spawns
|
|
123
|
+
* it as given rather than trying `.cmd` spellings through cmd.exe. Every caller
|
|
124
|
+
* already handles a cswap that is not there, and this is one — so a mutation
|
|
125
|
+
* that reaches it (an Add pressed from a panel drawn before the refusal) fails
|
|
126
|
+
* the way a missing tool fails, and runs nothing.
|
|
127
|
+
*/
|
|
128
|
+
const REFUSED_BIN = join(homedir(), ".agents-deck", "cswap-refused", "cswap.refused");
|
|
129
|
+
|
|
130
|
+
/** The refusal in effect for THIS process, `{ path, version, want, via }` — set
|
|
131
|
+
* when an install is refused, or when cswapBin resolves a copy the record
|
|
132
|
+
* names. */
|
|
133
|
+
let _refused = null;
|
|
134
|
+
|
|
135
|
+
function readRefusal() {
|
|
136
|
+
try {
|
|
137
|
+
const r = JSON.parse(readFileSync(REFUSED_RECORD, "utf8"));
|
|
138
|
+
return r && typeof r.version === "string" ? r : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clearRefusal() {
|
|
145
|
+
try { rmSync(REFUSED_RECORD, { force: true }); } catch { /* best-effort */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Refuse this copy from now on, in this process and in the next. */
|
|
149
|
+
function refuse(entry) {
|
|
150
|
+
_refused = entry;
|
|
151
|
+
_bin = null;
|
|
152
|
+
_probe = null;
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
|
|
155
|
+
writeFileSync(REFUSED_RECORD, JSON.stringify({ ...entry, at: new Date().toISOString() }, null, 2) + "\n");
|
|
156
|
+
} catch { /* the in-process refusal still holds; a note on disk is the next launch's */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The refusal cswapBin's resolution found, or null — `{ path, version, want,
|
|
161
|
+
* via }`. As of the last cswapBin(), so a caller that has just awaited one can
|
|
162
|
+
* ask this without another hop; one that has not wants cswapRefusal.
|
|
163
|
+
*/
|
|
164
|
+
export function cswapRefused() {
|
|
165
|
+
return process.env.AGENTS_DECK_CSWAP ? null : _refused;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The refusal this deck is keeping, or null, for a caller that may be asking
|
|
170
|
+
* before anything has resolved the binary: the roster and the first-run seed.
|
|
171
|
+
* Resolves it first only when there is a record to match and nothing has been
|
|
172
|
+
* resolved yet, so a process pays one `--version` for the question and a
|
|
173
|
+
* machine with no record pays a missing file.
|
|
174
|
+
*/
|
|
175
|
+
export async function cswapRefusal() {
|
|
176
|
+
if (process.env.AGENTS_DECK_CSWAP) return null;
|
|
177
|
+
if (!_refused && !_bin && existsSync(REFUSED_RECORD)) await cswapBin();
|
|
178
|
+
return _refused;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** ensureCswap's answer for a refused copy: the install-time row's shape, so
|
|
182
|
+
* the boot says the same thing on every launch the refusal holds. */
|
|
183
|
+
function refusedState(r) {
|
|
184
|
+
return { state: "unavailable", reason: "unexpected_version", version: r.version, want: r.want ?? RANGE_SPEC, via: r.via ?? null };
|
|
185
|
+
}
|
|
186
|
+
|
|
95
187
|
/**
|
|
96
188
|
* What the probe that resolved `_bin` printed, and when.
|
|
97
189
|
*
|
|
@@ -136,9 +228,19 @@ export async function cswapBin() {
|
|
|
136
228
|
// debugging a bad resolution needs it to take effect immediately.
|
|
137
229
|
if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
|
|
138
230
|
if (_bin) return _bin;
|
|
231
|
+
if (_refused) return REFUSED_BIN;
|
|
139
232
|
|
|
140
233
|
const take = (spelling, r) => {
|
|
141
|
-
|
|
234
|
+
const version = versionIn(r);
|
|
235
|
+
// The copy the deck refused, still answering: decline it (#1799). Any
|
|
236
|
+
// other version is another copy, and the record goes.
|
|
237
|
+
const refused = readRefusal();
|
|
238
|
+
if (refused && refused.version === version) {
|
|
239
|
+
_refused = refused;
|
|
240
|
+
return REFUSED_BIN;
|
|
241
|
+
}
|
|
242
|
+
if (refused) clearRefusal();
|
|
243
|
+
_probe = { version, at: Date.now() };
|
|
142
244
|
return (_bin = spelling);
|
|
143
245
|
};
|
|
144
246
|
|
|
@@ -173,11 +275,12 @@ export async function cswapBin() {
|
|
|
173
275
|
* ensureCswap below, whose return value says nothing about which binary the
|
|
174
276
|
* following twenty account operations will be sent to. See cswap-bin-memo.test.ts.
|
|
175
277
|
*/
|
|
176
|
-
export function resetCswapBin() { _bin = null; _probe = null; }
|
|
278
|
+
export function resetCswapBin() { _bin = null; _probe = null; _refused = null; }
|
|
177
279
|
|
|
178
280
|
/** Installed version string, or null when cswap cannot be found. */
|
|
179
281
|
export async function cswapVersion() {
|
|
180
282
|
const bin = await cswapBin();
|
|
283
|
+
if (bin === REFUSED_BIN) return null; // declined, not asked — see _refused
|
|
181
284
|
// The call above may have just asked this very question — see _probe. Nothing
|
|
182
285
|
// is remembered past PROBE_FRESH_MS, so this is the second half of one
|
|
183
286
|
// lookup rather than a cache of the answer.
|
|
@@ -600,10 +703,17 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
600
703
|
|
|
601
704
|
if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
|
|
602
705
|
const version = await cswapVersion();
|
|
706
|
+
if (cswapRefused()) return refusedState(cswapRefused());
|
|
603
707
|
return version ? { state: "present", version } : { state: "skipped" };
|
|
604
708
|
}
|
|
605
709
|
|
|
606
710
|
const existing = await cswapVersion();
|
|
711
|
+
// STILL THE COPY IT REFUSED (#1799). Asked before the existing-install branch,
|
|
712
|
+
// which accepts any version on purpose and would call this one present, and
|
|
713
|
+
// before the install below, which would fetch the same answer from the same
|
|
714
|
+
// index on every launch. The boot row repeats the refusal instead, and nothing
|
|
715
|
+
// is seeded.
|
|
716
|
+
if (cswapRefused()) return refusedState(cswapRefused());
|
|
607
717
|
if (existing) {
|
|
608
718
|
// Installed — the only question left is whether it's stale. One PyPI
|
|
609
719
|
// request a day, and the upgrade itself never blocks startup.
|
|
@@ -650,6 +760,9 @@ async function installAndConfirm() {
|
|
|
650
760
|
// found nothing — see resetCswapBin — but a lookup that DID resolve, to a copy
|
|
651
761
|
// whose `--version` then failed, is exactly the case that got us here.
|
|
652
762
|
resetCswapBin();
|
|
763
|
+
// And whatever was refused before: what arrived is judged below, afresh, and
|
|
764
|
+
// an acceptable version is the end of the refusal.
|
|
765
|
+
clearRefusal();
|
|
653
766
|
|
|
654
767
|
// Freshly installed tools land in ~/.local/bin, which may not be on the PATH
|
|
655
768
|
// of the shell that launched us — cswapBin looks there directly, so this
|
|
@@ -675,6 +788,8 @@ async function installAndConfirm() {
|
|
|
675
788
|
if (version !== "installed") {
|
|
676
789
|
const wrong = result.want ? !sameVersion(version, result.want) : !isAcceptableVersion(version);
|
|
677
790
|
if (wrong) {
|
|
791
|
+
// Kept, not only reported (#1799): see REFUSED_RECORD.
|
|
792
|
+
refuse({ path: _bin, version, want: result.want ?? RANGE_SPEC, via: result.via });
|
|
678
793
|
return { state: "unavailable", reason: "unexpected_version", version, want: result.want ?? RANGE_SPEC, via: result.via };
|
|
679
794
|
}
|
|
680
795
|
}
|
|
@@ -212,6 +212,10 @@ function normaliseLan(raw) {
|
|
|
212
212
|
// When somebody here said yes. Absent on every pin made before this was
|
|
213
213
|
// kept, and absent is drawn as "before" rather than guessed.
|
|
214
214
|
...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
|
|
215
|
+
// Said yes to by the accept switch rather than by a person — see
|
|
216
|
+
// addTrusted in lan-sync.mjs. Kept, or a restart would turn every
|
|
217
|
+
// pairing the switch made into one somebody chose. Only a real `true`.
|
|
218
|
+
...(t.auto === true ? { auto: true } : {}),
|
|
215
219
|
})),
|
|
216
220
|
// Fingerprints somebody explicitly unpaired. Kept separately from trusted
|
|
217
221
|
// because manual dial rows survive the action and must not silently rebuild
|
|
@@ -72,6 +72,9 @@ const blockNotifier = createBlockNotifier({
|
|
|
72
72
|
// menu while the deck is running, and a mute that waited for a restart would
|
|
73
73
|
// not be a mute. The env var still wins inside `notificationsOn`.
|
|
74
74
|
enabled: () => notificationsOn(heldPrefs.current()),
|
|
75
|
+
// Asked again when the burst cap's window reopens, for the one notice that
|
|
76
|
+
// stands for what it held back — see BURST_MAX.
|
|
77
|
+
pages: () => pageCount(),
|
|
75
78
|
onError: err => console.error(`${PRODUCT}: could not raise a desktop notification:`, err?.message ?? err),
|
|
76
79
|
});
|
|
77
80
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// envelope and evicts behind it, clearEventBuffer empties all three, and the
|
|
9
9
|
// readers get a copy or an answer rather than the array. The bodies are
|
|
10
10
|
// unchanged.
|
|
11
|
-
import { ENVELOPE_CHARS,
|
|
11
|
+
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
|
|
12
12
|
|
|
13
13
|
// ─── The event ring buffer ─────────────────────────────────────────────────
|
|
14
14
|
// Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
|
|
@@ -148,7 +148,7 @@ export function admitEvent(raw, source, receivedAt) {
|
|
|
148
148
|
// Decided once, here, beside the charge and for the same reason: eviction
|
|
149
149
|
// must never have to look at a payload again to know what it is giving
|
|
150
150
|
// back. See LAST_VALUE_WINS.
|
|
151
|
-
[ENRICHMENT]:
|
|
151
|
+
[ENRICHMENT]: isEnrichment(raw),
|
|
152
152
|
};
|
|
153
153
|
events.push(evt);
|
|
154
154
|
bufferedChars += evt[CHARS];
|
|
@@ -26,7 +26,8 @@ function currentLiveVideo(html) {
|
|
|
26
26
|
|
|
27
27
|
export async function fetchGoodLifeRadio({ fetchImpl } = {}) {
|
|
28
28
|
const now = Date.now();
|
|
29
|
-
|
|
29
|
+
// The stamp, not the value: a miss is remembered too, as null.
|
|
30
|
+
if (cacheAt && now - cacheAt < cacheTtl) return cache;
|
|
30
31
|
if (inflight) return inflight;
|
|
31
32
|
const get = fetchImpl ?? fetch;
|
|
32
33
|
inflight = (async () => {
|
package/src/server/index.mjs
CHANGED
|
@@ -136,7 +136,7 @@ import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "
|
|
|
136
136
|
// GET and POST /api/prefs — see prefs-routes.mjs.
|
|
137
137
|
import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
|
|
138
138
|
import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
|
|
139
|
-
import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
|
|
139
|
+
import { historySnapshot, processesReply, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
|
|
140
140
|
// How every route reads a body and answers, and the answer for one that threw
|
|
141
141
|
// — see http-io.mjs. sendInternalError was exported from this file before it
|
|
142
142
|
// moved, and still is.
|
|
@@ -238,6 +238,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
238
238
|
host: req.headers.host,
|
|
239
239
|
secFetchSite: req.headers["sec-fetch-site"],
|
|
240
240
|
referer: req.headers.referer,
|
|
241
|
+
token: req.headers["x-ccdeck-token"],
|
|
241
242
|
})) {
|
|
242
243
|
return send(res, 403, { error: "cross-site request blocked" });
|
|
243
244
|
}
|
|
@@ -309,7 +310,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
309
310
|
// draws four columns and needs none of it, so on a deck where the modal
|
|
310
311
|
// is never opened the argv is never read at all.
|
|
311
312
|
const detail = url.searchParams.get("detail") === "1";
|
|
312
|
-
return guard(readProcesses(process.platform, detail).then(r => send(res, 200,
|
|
313
|
+
return guard(readProcesses(process.platform, detail).then(r => send(res, 200, processesReply(r))), res);
|
|
313
314
|
}
|
|
314
315
|
// A day of minute buckets, which is far too much to ride along on
|
|
315
316
|
// /api/system's three-second poll for a chart that is usually closed. Its
|
package/src/server/installer.mjs
CHANGED
|
@@ -106,21 +106,68 @@ export function hookCommand(installedHookPath, provider, node = process.execPath
|
|
|
106
106
|
return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function
|
|
110
|
-
if (!g || typeof g !== "object") return false;
|
|
109
|
+
function isMarked(g) {
|
|
111
110
|
if (g[MARK_KEY] === true) return true;
|
|
112
111
|
for (const k of LEGACY_MARKS) if (g[k] === true) return true;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether one command is our forwarder. In a group we marked, any path ending
|
|
117
|
+
* in one of our directories' hook.js — the config dir may be relocated and so
|
|
118
|
+
* not be called `.claude` at all. Anywhere else only the `.claude`/`.codex`
|
|
119
|
+
* spelling, which is what an entry from before the mark, or one written by
|
|
120
|
+
* hand, looks like.
|
|
121
|
+
*/
|
|
122
|
+
function isOurCommand(h, marked) {
|
|
123
|
+
const c = typeof h?.command === "string" ? h.command : "";
|
|
124
|
+
for (const dir of LEGACY_DIRS) {
|
|
125
|
+
if (c.includes(`.claude/${dir}/hook.js`) || c.includes(`.claude\\${dir}\\hook.js`)) return true;
|
|
126
|
+
if (c.includes(`.codex/${dir}/hook.js`) || c.includes(`.codex\\${dir}\\hook.js`)) return true;
|
|
127
|
+
if (marked && (c.includes(`/${dir}/hook.js`) || c.includes(`\\${dir}\\hook.js`))) return true;
|
|
120
128
|
}
|
|
121
129
|
return false;
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
/** What withoutOurCommands answers for a group with nothing left in it. */
|
|
133
|
+
const DROP = Symbol("drop");
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One group with our forwarders taken out of it: `g` itself when nothing in it
|
|
137
|
+
* was ours, DROP when nothing is left, and otherwise a copy holding the rest
|
|
138
|
+
* with its matcher and without our marks.
|
|
139
|
+
*
|
|
140
|
+
* PER COMMAND, NOT PER GROUP (#1734). This used to decide for the whole group —
|
|
141
|
+
* marked, or one command naming our hook.js — and drop all of it. But the group
|
|
142
|
+
* we write has no matcher, and Claude Code's own hook editor files a new
|
|
143
|
+
* matcher-less hook into the first group for that event whose matcher is
|
|
144
|
+
* missing or empty, which on a machine with no hook of the user's own for that
|
|
145
|
+
* event is ours. The next start then rewrote settings.json without the user's
|
|
146
|
+
* sound or PreToolUse guard, and said nothing. Only our forwarder is ours.
|
|
147
|
+
*
|
|
148
|
+
* A marked group whose `hooks` is not a list is dropped, as it always was; an
|
|
149
|
+
* unmarked one is left exactly where it is, as it always was.
|
|
150
|
+
*/
|
|
151
|
+
function withoutOurCommands(g) {
|
|
152
|
+
if (!g || typeof g !== "object") return g;
|
|
153
|
+
const marked = isMarked(g);
|
|
154
|
+
if (!Array.isArray(g.hooks)) return marked ? DROP : g;
|
|
155
|
+
const kept = g.hooks.filter(h => !isOurCommand(h, marked));
|
|
156
|
+
if (!marked && kept.length === g.hooks.length) return g;
|
|
157
|
+
if (kept.length === 0) return DROP;
|
|
158
|
+
const rest = {};
|
|
159
|
+
for (const [k, v] of Object.entries(g)) {
|
|
160
|
+
if (k === MARK_KEY || LEGACY_MARKS.includes(k)) continue;
|
|
161
|
+
rest[k] = k === "hooks" ? kept : v;
|
|
162
|
+
}
|
|
163
|
+
return rest;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether a group holds anything of ours — a forwarder, or our mark. */
|
|
167
|
+
function isOurEntry(g) {
|
|
168
|
+
return withoutOurCommands(g) !== g;
|
|
169
|
+
}
|
|
170
|
+
|
|
124
171
|
async function ensureDir(p) {
|
|
125
172
|
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
|
126
173
|
}
|
|
@@ -207,7 +254,7 @@ function buildHookEntry(command) {
|
|
|
207
254
|
|
|
208
255
|
function dedupeOurEntries(group) {
|
|
209
256
|
if (!Array.isArray(group)) return [];
|
|
210
|
-
return group.filter(g =>
|
|
257
|
+
return group.map(withoutOurCommands).filter(g => g !== DROP);
|
|
211
258
|
}
|
|
212
259
|
|
|
213
260
|
/**
|
|
@@ -422,8 +469,11 @@ export async function uninstallHooks({ provider = "claude", beforeWrite = null }
|
|
|
422
469
|
if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
|
|
423
470
|
let changed = false;
|
|
424
471
|
for (const evt of Object.keys(current.hooks)) {
|
|
425
|
-
const
|
|
426
|
-
|
|
472
|
+
const was = current.hooks[evt];
|
|
473
|
+
const cleaned = dedupeOurEntries(was);
|
|
474
|
+
// A count alone no longer sees every change: a group that loses our
|
|
475
|
+
// forwarder but keeps the user's own hooks is still there, as a new object.
|
|
476
|
+
if (cleaned.length !== (was?.length ?? 0) || cleaned.some((g, i) => g !== was[i])) changed = true;
|
|
427
477
|
if (cleaned.length === 0) delete current.hooks[evt];
|
|
428
478
|
else current.hooks[evt] = cleaned;
|
|
429
479
|
}
|
|
@@ -305,10 +305,12 @@ export function createBeacon({
|
|
|
305
305
|
announce(via === "tailscale" ? [rinfo.address] : []);
|
|
306
306
|
}
|
|
307
307
|
if (verdict !== "peer") {
|
|
308
|
-
// Another deck
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
if
|
|
308
|
+
// Another deck may be using this one's key — see beaconVerdict. Reported
|
|
309
|
+
// with where the beacon says that deck listens, rather than acted on
|
|
310
|
+
// here: this file carries packets, and finding out whether anything
|
|
311
|
+
// there holds the key, and choosing a new identity if it does, belong to
|
|
312
|
+
// whoever stores it.
|
|
313
|
+
if (verdict === "id-clash") onIdClash?.({ addr: rinfo.address, port: beacon.port });
|
|
312
314
|
// A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
|
|
313
315
|
// dropped: it is a name and an address on the same network, which is a
|
|
314
316
|
// row somebody can accept. Nothing is asked of it and nothing is
|
package/src/server/lan-call.mjs
CHANGED
|
@@ -200,6 +200,10 @@ export function connectToPeer({
|
|
|
200
200
|
resolve({
|
|
201
201
|
sock, key, sealed: !!chan,
|
|
202
202
|
peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
|
|
203
|
+
// The challenge the far end made for this connection, so a deck that
|
|
204
|
+
// reached its own key can ask its own listener whether it made it —
|
|
205
|
+
// see `issued` in lan-socket.mjs.
|
|
206
|
+
peerChallenge: theirChallenge,
|
|
203
207
|
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
204
208
|
/** What a frame from the other end says — or null on a sealed
|
|
205
209
|
* connection when it does not open, which ends the connection: every
|
package/src/server/lan-dials.mjs
CHANGED
|
@@ -137,9 +137,18 @@ export function createDials() {
|
|
|
137
137
|
rowAnswering: fp => [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp),
|
|
138
138
|
|
|
139
139
|
/** A row put on for a paired deck that called in from it, on trial until a
|
|
140
|
-
* round proves the address answers — see calledBack.
|
|
140
|
+
* round proves the address answers — see calledBack.
|
|
141
|
+
*
|
|
142
|
+
* NEVER A ROW SOMEBODY TYPED (#1741). learnCaller asks for a trial
|
|
143
|
+
* whenever `add` says yes, and `add` says yes for an address already on
|
|
144
|
+
* the list and keeps a typed row typed — so a deck calling in from the
|
|
145
|
+
* exact address somebody typed, before a round here had reached it, put
|
|
146
|
+
* that row on trial and the next failed round took it away. It is not
|
|
147
|
+
* the deck's to take away (#1674); who called from there is still worth
|
|
148
|
+
* knowing, so the row reads as that deck. */
|
|
141
149
|
trial(at, met) {
|
|
142
150
|
learned.set(at, met);
|
|
151
|
+
if (manual.get(at)?.typed) return;
|
|
143
152
|
calledBack.add(at);
|
|
144
153
|
},
|
|
145
154
|
/** A round reached `host:port` and `met` is who answered there. A
|