ccdeck 3.43.3 → 3.44.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 +74 -20
- package/THIRD_PARTY_NOTICES.md +43 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +60 -16
- package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
- package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
- package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
- package/dist/web/assets/app-CQIstkMF.js +318 -0
- package/dist/web/assets/app-Ch-0CYlK.css +1 -0
- package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +40 -0
- package/src/server/account-notify.mjs +206 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +48 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +115 -14
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +30 -6
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/index.mjs +17 -1
- package/src/server/installer.mjs +149 -8
- package/src/server/lan-copies.mjs +43 -5
- package/src/server/lan-deck.mjs +3 -0
- package/src/server/lan-engine.mjs +7 -2
- package/src/server/lan-manifest.mjs +2 -2
- package/src/server/lan-serve.mjs +17 -2
- package/src/server/lan-sync.mjs +5 -5
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/process-command.mjs +4 -1
- package/src/server/reports.mjs +7 -1
- package/src/server/request-gates.mjs +7 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/traffic-radar-wire.mjs +4 -3
- package/src/server/usage-day.mjs +1 -1
- package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
- package/dist/web/assets/app-DsHSufBt.css +0 -1
- package/dist/web/assets/app-HpFVXK_g.js +0 -310
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// How a command typed in a terminal talks to the deck already running (#2183).
|
|
2
|
+
//
|
|
3
|
+
// `ccdeck accounts` and `ccdeck switch` are asked over SSH as often as at the
|
|
4
|
+
// desk, by somebody who has no tab open and no wish to forward one. The deck
|
|
5
|
+
// already answers everything they need — the roster, the switch, the sessions
|
|
6
|
+
// it is watching — so the command is a client of it rather than a second
|
|
7
|
+
// implementation beside it.
|
|
8
|
+
//
|
|
9
|
+
// AND IT HAS TO BE. The account store's mutex is in the deck's own process
|
|
10
|
+
// (store-lock.mjs, "not defence against another process"), so a terminal that
|
|
11
|
+
// ran `cswap switch` itself while a deck was up would race the Accounts panel,
|
|
12
|
+
// the auto-switch tick and a sign-in in flight — the race #1039 closed inside
|
|
13
|
+
// the deck. Through the deck, the switch takes that lock, invalidates the
|
|
14
|
+
// roster and the quota the panel is showing, and lands in the swap log, exactly
|
|
15
|
+
// as a click does.
|
|
16
|
+
//
|
|
17
|
+
// THE SAME DOOR `--stop` USES. The registry record (0600, this user's) holds
|
|
18
|
+
// the deck's port and token; a request on 127.0.0.1 with `x-ccdeck-token` and
|
|
19
|
+
// no browser headers is what request-gates.mjs admits for a guarded read and a
|
|
20
|
+
// mutation alike. The token is read again on every call, because a deck that
|
|
21
|
+
// restarted has a new one.
|
|
22
|
+
//
|
|
23
|
+
// A leaf, like stop-deck.mjs: node:http and running-deck.mjs, and nothing that
|
|
24
|
+
// starts a server or arms a timer.
|
|
25
|
+
import { request as httpRequest } from "node:http";
|
|
26
|
+
import { liveDecks } from "./running-deck.mjs";
|
|
27
|
+
|
|
28
|
+
/** Long enough for a roster read, which can wait on claude-swap's store; short
|
|
29
|
+
* enough that a wedged deck does not hold the terminal. A switch passes its
|
|
30
|
+
* own, since `cswap switch` is given thirty seconds inside the deck. */
|
|
31
|
+
export const ASK_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The deck a command talks to, or null when none is running.
|
|
35
|
+
*
|
|
36
|
+
* `--port` names one. Otherwise there is meant to be exactly one — a start
|
|
37
|
+
* keeps at most one per Claude config directory (running-deck.mjs) — and when a
|
|
38
|
+
* leftover from before that rule is still up, the newest version wins, then the
|
|
39
|
+
* lowest port, so the answer is the same every time it is asked.
|
|
40
|
+
*/
|
|
41
|
+
export async function findDeck({ port = null, live = liveDecks } = {}) {
|
|
42
|
+
const decks = await live().catch(() => []);
|
|
43
|
+
if (port != null) return decks.find(d => d.port === Number(port)) ?? null;
|
|
44
|
+
if (!decks.length) return null;
|
|
45
|
+
return [...decks].sort((a, b) => newer(b.version, a.version) || a.port - b.port)[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Positive when `a` is a newer x.y.z than `b`. A missing version is oldest. */
|
|
49
|
+
function newer(a, b) {
|
|
50
|
+
const parts = (v) => String(v ?? "").split(".").map(n => Number.parseInt(n, 10) || 0);
|
|
51
|
+
const [x, y] = [parts(a), parts(b)];
|
|
52
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One request to a deck, answered as `{ ok, status, body }`.
|
|
58
|
+
*
|
|
59
|
+
* Never rejects, for the reason askDeckToStop gives: every failure is a verdict
|
|
60
|
+
* the caller has something to say about. `ok` is the HTTP status, and `body` the
|
|
61
|
+
* parsed JSON or null — the deck's own `{ ok: false, reason }` arrives with a
|
|
62
|
+
* 400 and is worth more to the reader than the status alone, so it is kept.
|
|
63
|
+
*/
|
|
64
|
+
export function askDeck(rec, { method = "GET", path, body = undefined, timeoutMs = ASK_MS, request = httpRequest } = {}) {
|
|
65
|
+
return new Promise(resolve => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
68
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
69
|
+
let req;
|
|
70
|
+
try {
|
|
71
|
+
req = request({
|
|
72
|
+
hostname: "127.0.0.1",
|
|
73
|
+
port: rec.port,
|
|
74
|
+
path,
|
|
75
|
+
method,
|
|
76
|
+
timeout: timeoutMs,
|
|
77
|
+
headers: {
|
|
78
|
+
// The token, and nothing a browser would send: a request that carried
|
|
79
|
+
// an Origin or Sec-Fetch-Site would be judged as a page's, and a page
|
|
80
|
+
// is not who this is.
|
|
81
|
+
"x-ccdeck-token": String(rec.token ?? ""),
|
|
82
|
+
...(payload === null
|
|
83
|
+
? { "content-length": "0" }
|
|
84
|
+
: { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }),
|
|
85
|
+
},
|
|
86
|
+
}, res => {
|
|
87
|
+
let text = "";
|
|
88
|
+
res.setEncoding("utf8");
|
|
89
|
+
res.on("data", d => { text += d; });
|
|
90
|
+
res.on("end", () => {
|
|
91
|
+
let parsed = null;
|
|
92
|
+
try { parsed = JSON.parse(text); } catch { /* not JSON: the status speaks */ }
|
|
93
|
+
const status = res.statusCode ?? 0;
|
|
94
|
+
finish({ ok: status >= 200 && status < 300, status, body: parsed });
|
|
95
|
+
});
|
|
96
|
+
// A reply cut off half way — the deck stopped, or its socket did —
|
|
97
|
+
// ends with neither `end` nor a request error, and a promise left
|
|
98
|
+
// waiting for one is a command that never returns.
|
|
99
|
+
res.on("error", () => {});
|
|
100
|
+
res.on("close", () => {
|
|
101
|
+
if (!res.complete) finish({ ok: false, status: res.statusCode ?? 0, body: null, reason: "cut_off" });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return finish({ ok: false, status: 0, body: null, reason: err?.code ?? "request_failed" });
|
|
106
|
+
}
|
|
107
|
+
req.on("error", err => finish({ ok: false, status: 0, body: null, reason: err?.code ?? "unreachable" }));
|
|
108
|
+
req.on("timeout", () => { req.destroy(); finish({ ok: false, status: 0, body: null, reason: "timeout" }); });
|
|
109
|
+
if (payload !== null) req.write(payload);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -84,6 +84,15 @@ export const DEFAULTS = Object.freeze({
|
|
|
84
84
|
notifySwap: true,
|
|
85
85
|
notifyQuota: false,
|
|
86
86
|
notifyReset: false,
|
|
87
|
+
// Sessions that stopped on an expired login or a usage limit, said once per
|
|
88
|
+
// incident (account-notify.mjs). ON for the swap's reason: it is rare, every
|
|
89
|
+
// running session is stopped when it happens, and nothing else says so.
|
|
90
|
+
notifyStopped: true,
|
|
91
|
+
// Continue a BACKGROUND session whose turn stopped on an expired login or a
|
|
92
|
+
// usage limit, once a working login with room is active again — once per
|
|
93
|
+
// stop (stop-continue.mjs, #2193). OFF: it stops and restarts a session's
|
|
94
|
+
// process on the user's behalf, and that is something a person turns on.
|
|
95
|
+
continueStopped: false,
|
|
87
96
|
// The Electron window can open on a new localhost port after an update.
|
|
88
97
|
// Its tour marker must outlive that origin; browser tabs still use their
|
|
89
98
|
// own localStorage marker and never read this preference.
|
|
@@ -158,6 +167,16 @@ export const DEFAULTS = Object.freeze({
|
|
|
158
167
|
// of one person's machines show each other where they are working; off, and
|
|
159
168
|
// paired decks read "current account hidden" instead.
|
|
160
169
|
shareActive: true,
|
|
170
|
+
// THE LOGIN THIS DECK IS USING, SENT TO A PAIRED DECK, off unless somebody
|
|
171
|
+
// turns it on (#2192). A ticked account goes to every paired deck that
|
|
172
|
+
// lacks it or holds an expired copy — and when it is the one this deck is
|
|
173
|
+
// signed in to, the two machines then hold one login. A Claude login renews
|
|
174
|
+
// once: whichever copy renews it first keeps it, and the other machine is
|
|
175
|
+
// signed out. So while this deck is on an account, that account stays
|
|
176
|
+
// here, and goes out like any other the moment this deck switches away. A
|
|
177
|
+
// new field, so nobody's saved choice is changed: the ticks are untouched,
|
|
178
|
+
// and what they hand out is every login except the one in use.
|
|
179
|
+
sendInUse: false,
|
|
161
180
|
// FINDING THIS PERSON'S OTHER MACHINES OVER TAILSCALE, off until they turn
|
|
162
181
|
// it on: it is a new path off this machine, and the owner chooses it. Its
|
|
163
182
|
// two permissions ship on, like the local pair above, and they are narrower
|
|
@@ -298,6 +317,9 @@ function normaliseLan(raw) {
|
|
|
298
317
|
// Whether paired decks are told which shared account this one is on. Absent
|
|
299
318
|
// is on — the default above — and only a real boolean turns it off.
|
|
300
319
|
shareActive: flagOr(src.shareActive, DEFAULTS.lan.shareActive),
|
|
320
|
+
// Whether the login in use here may be sent. Off unless a real boolean
|
|
321
|
+
// says on.
|
|
322
|
+
sendInUse: flagOr(src.sendInUse, DEFAULTS.lan.sendInUse),
|
|
301
323
|
// The Tailscale switch and its two permissions. Only a real boolean
|
|
302
324
|
// overrides a default, as above.
|
|
303
325
|
tailscale: flagOr(src.tailscale, DEFAULTS.lan.tailscale),
|
|
@@ -321,6 +343,8 @@ export function normalise(raw) {
|
|
|
321
343
|
notifySwap: flagOr(src.notifySwap, DEFAULTS.notifySwap),
|
|
322
344
|
notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
|
|
323
345
|
notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
|
|
346
|
+
notifyStopped: flagOr(src.notifyStopped, DEFAULTS.notifyStopped),
|
|
347
|
+
continueStopped: flagOr(src.continueStopped, DEFAULTS.continueStopped),
|
|
324
348
|
tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
|
|
325
349
|
autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
|
|
326
350
|
reports: flagOr(src.reports, DEFAULTS.reports),
|
|
@@ -705,9 +729,9 @@ export function publicPrefs(prefs) {
|
|
|
705
729
|
* here — prefs-route-fields.test.ts lists them.
|
|
706
730
|
*/
|
|
707
731
|
export const PAGE_FIELDS = Object.freeze({
|
|
708
|
-
top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
|
|
732
|
+
top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "notifyStopped", "continueStopped", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
|
|
709
733
|
lan: Object.freeze([
|
|
710
|
-
"enabled", "name", "shared", "manual", "shareActive", "pairingMode",
|
|
734
|
+
"enabled", "name", "shared", "manual", "shareActive", "sendInUse", "pairingMode",
|
|
711
735
|
"autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
|
|
712
736
|
]),
|
|
713
737
|
});
|
|
@@ -739,15 +763,15 @@ export function notificationsOn(prefs, env = process.env) {
|
|
|
739
763
|
}
|
|
740
764
|
|
|
741
765
|
/**
|
|
742
|
-
* Which account notifications may go out right now: the
|
|
743
|
-
* on its own, and all
|
|
766
|
+
* Which account notifications may go out right now: the four switches, each
|
|
767
|
+
* on its own, and all four off when the machine vetoed notifications at
|
|
744
768
|
* launch — AGENTS_DECK_NO_NOTIFY=1 keeps the deck off the desktop whatever
|
|
745
769
|
* else it was told, and these are desktop notifications too.
|
|
746
770
|
*/
|
|
747
771
|
export function accountNotifySettings(prefs, env = process.env) {
|
|
748
|
-
if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false };
|
|
772
|
+
if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false, stopped: false };
|
|
749
773
|
const p = normalise(prefs);
|
|
750
|
-
return { swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset };
|
|
774
|
+
return { swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset, stopped: p.notifyStopped };
|
|
751
775
|
}
|
|
752
776
|
|
|
753
777
|
/**
|
|
@@ -14,9 +14,13 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
14
14
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
15
15
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
16
16
|
import { accountNotifySettings, notificationsOn } from "./deck-prefs.mjs";
|
|
17
|
-
// The account notifier — a switch, a quota threshold, a reset
|
|
18
|
-
// to the same OS call the block notifier
|
|
19
|
-
|
|
17
|
+
// The account notifier — a switch, a quota threshold, a reset, sessions stopped
|
|
18
|
+
// on a dead login — connected below to the same OS call the block notifier
|
|
19
|
+
// uses, and the door a stopped turn is reported through.
|
|
20
|
+
import { connectAccountNotify, noteHookStop } from "./account-watch.mjs";
|
|
21
|
+
// The same stops, and what shows each session working again, for the Continue
|
|
22
|
+
// action and the automatic continue of background sessions (#2193).
|
|
23
|
+
import { noteStopContinue } from "./stop-continue.mjs";
|
|
20
24
|
// The settings as this process holds them — see prefs-state.mjs.
|
|
21
25
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
22
26
|
// The deck's own token, taken back out of every event before the ring, the
|
|
@@ -205,6 +209,15 @@ export function pushEvent(raw, source, opts = {}) {
|
|
|
205
209
|
// know the other exists. block-notify.mjs holds the gates and the cooldown.
|
|
206
210
|
blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
|
|
207
211
|
|
|
212
|
+
// A turn this machine's Claude Code ended on an expired login or a usage
|
|
213
|
+
// limit: one notification for the incident, and an auto-switch tick now
|
|
214
|
+
// rather than at the next interval (account-watch.mjs). A hook's own post
|
|
215
|
+
// only — a replay is history, and the scanners never send one.
|
|
216
|
+
if (source === "hook" && !opts.replay) {
|
|
217
|
+
noteHookStop(raw, evt.receivedAt);
|
|
218
|
+
noteStopContinue(raw, evt.receivedAt);
|
|
219
|
+
}
|
|
220
|
+
|
|
208
221
|
// Persisted history is not current activity. Fresh recovery snapshots are
|
|
209
222
|
// current evidence even though they bypass replayed-tool accounting.
|
|
210
223
|
if (shouldTrackActivity(raw, opts)) activity.note(raw, evt.receivedAt);
|
|
@@ -241,6 +254,9 @@ export function pushEvent(raw, source, opts = {}) {
|
|
|
241
254
|
// Provider gates the path: Claude reads transcript_path; Codex reads its
|
|
242
255
|
// rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
|
|
243
256
|
// when transcript_path is absent (always the case for Codex hooks).
|
|
257
|
+
if (source === "codex" && !opts.replay && raw?.hook_event_name === "UsageObserved") {
|
|
258
|
+
maybeResolveCodex(raw, { persist: opts.persist });
|
|
259
|
+
}
|
|
244
260
|
if (source === "hook" && !opts.replay) {
|
|
245
261
|
// A hook is somebody running the CLI, which is what the day's usage counts.
|
|
246
262
|
usageDay.noteUse(raw);
|
|
@@ -17,6 +17,7 @@ import { presentsDeckToken } from "./request-gates.mjs";
|
|
|
17
17
|
import { SEQ_EPOCH, ringHoldsNewerThan, ringSnapshot } from "./event-ring.mjs";
|
|
18
18
|
// The one door every event comes through — see event-pipeline.mjs.
|
|
19
19
|
import { pushEvent } from "./event-pipeline.mjs";
|
|
20
|
+
import { noteToolAudit } from "./tool-audit.mjs";
|
|
20
21
|
import { noteLogWriter } from "./event-log.mjs";
|
|
21
22
|
// Which CLIs this deck watches — see deck-scope.mjs.
|
|
22
23
|
import { deckProviders } from "./deck-scope.mjs";
|
|
@@ -201,6 +202,20 @@ export function handleEventIngest(req, res, persist = true) {
|
|
|
201
202
|
if (!deckProviders().claude && parsed?.provider === "claude") {
|
|
202
203
|
return send(res, 409, { error: "this deck is not watching Claude" });
|
|
203
204
|
}
|
|
205
|
+
// A slash command expanding into a prompt (UserPromptExpansion) is counted
|
|
206
|
+
// for Skill activity and goes no further. The prompt it becomes arrives on
|
|
207
|
+
// its own UserPromptSubmit, the canvas has nothing to draw for it, and the
|
|
208
|
+
// reducer would otherwise treat an event it does not know as session
|
|
209
|
+
// traffic. So it never reaches the ring, the pages or the log.
|
|
210
|
+
if (parsed?.hook_event_name === "UserPromptExpansion") {
|
|
211
|
+
try {
|
|
212
|
+
noteToolAudit(parsed, "hook", {});
|
|
213
|
+
send(res, 200, { ok: true });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
sendInternalError(res, err);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
204
219
|
// Everything past the parse is inside one net, because this listener is the
|
|
205
220
|
// one place in the route table `guard` cannot reach. The route does wrap the
|
|
206
221
|
// call — `guard(handleEventIngest(req, res, …), res)` — but this function
|
package/src/server/exec.mjs
CHANGED
|
@@ -96,8 +96,12 @@ const TIMEOUT_TAIL = 8 << 10;
|
|
|
96
96
|
* `{...process.env, X: "1"}` to add to it. It exists because the quota probe
|
|
97
97
|
* runs a whole Claude Code and has to mark the run as the deck's own, and that
|
|
98
98
|
* marker is what stops every poll drawing itself onto the canvas.
|
|
99
|
+
*
|
|
100
|
+
* `cwd` is the child's working directory, and the deck's own when absent. It
|
|
101
|
+
* exists for `claude --resume <id> --bg` (stop-continue.mjs): Claude Code
|
|
102
|
+
* finds a conversation by the project it was started in, and resumes it there.
|
|
99
103
|
*/
|
|
100
|
-
export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } = {}) {
|
|
104
|
+
export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env, cwd } = {}) {
|
|
101
105
|
const tries = candidates(cmd);
|
|
102
106
|
return new Promise((resolve) => {
|
|
103
107
|
const attempt = (i) => {
|
|
@@ -166,7 +170,7 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
|
|
|
166
170
|
// process, so a shutdown that does not reap it leaves the child with
|
|
167
171
|
// no deadline at all. See `live` in exec-children.mjs, and #1012.
|
|
168
172
|
const cp = watchChild(execFile(file, argv,
|
|
169
|
-
{ timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done));
|
|
173
|
+
{ timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...(cwd ? { cwd } : {}), ...opts }, done));
|
|
170
174
|
// Give the child EOF on stdin straight away, which is what this
|
|
171
175
|
// function's contract has always claimed ("run closes stdin", says
|
|
172
176
|
// runInteractive's header) and what execFile does not do: it leaves the
|
package/src/server/index.mjs
CHANGED
|
@@ -29,6 +29,8 @@ export { MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./
|
|
|
29
29
|
// The ring itself — what it holds, how it numbers them, and the eviction that
|
|
30
30
|
// keeps those bounds — private behind its operations. See event-ring.mjs.
|
|
31
31
|
import { eventsSince } from "./event-ring.mjs";
|
|
32
|
+
// The same ring folded to one row per session, for `ccdeck sessions` (#2183).
|
|
33
|
+
import { summarizeSessions } from "./session-summary.mjs";
|
|
32
34
|
// Exported from this file before they moved, and still.
|
|
33
35
|
export { eventBufferStats, eventsSince } from "./event-ring.mjs";
|
|
34
36
|
// The one door every event comes through, and what it does to each on the way
|
|
@@ -36,6 +38,7 @@ export { eventBufferStats, eventsSince } from "./event-ring.mjs";
|
|
|
36
38
|
// routes below: it connects pushEvent to event-sink.mjs as it loads, and the
|
|
37
39
|
// boot replay pushes through that sink before any route has run.
|
|
38
40
|
import "./event-pipeline.mjs";
|
|
41
|
+
import { stopCodexEnrichment } from "./codex-enrichment.mjs";
|
|
39
42
|
// POST /api/event, GET /events and GET /api/events — see event-routes.mjs.
|
|
40
43
|
import { handleEventIngest, handleSse, writeJsonArray } from "./event-routes.mjs";
|
|
41
44
|
// Exported from this file before it moved, and still.
|
|
@@ -159,6 +162,7 @@ import { send, sendInternalError } from "./http-io.mjs";
|
|
|
159
162
|
export { sendInternalError };
|
|
160
163
|
// The accounts surface's routes — see account-routes.mjs. The boot wires the
|
|
161
164
|
// stale-copy repair and starts auto-switch through the same module.
|
|
165
|
+
import { handleStopContinue, handleStopContinueRead } from "./stop-continue.mjs";
|
|
162
166
|
import { cswapAutoModule, getProjectRollup, handleAccountLoginState, handleAccountProjects, handleClaudeAccountAdmin, handleClaudeAccountSwitch, handleClaudeAccounts, handleCswapAuto, handleCswapAutoAction, wireAccountOrigins, wireStaleCopyRepair } from "./account-routes.mjs";
|
|
163
167
|
// Browser Watch's three routes — see browser-watch-routes.mjs.
|
|
164
168
|
import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettings } from "./browser-watch-routes.mjs";
|
|
@@ -389,6 +393,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
389
393
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
|
|
390
394
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
|
|
391
395
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
|
|
396
|
+
// Continue a session whose turn stopped on a login or a usage limit (#2193):
|
|
397
|
+
// the gate's evidence and each session's outcome, and the press itself.
|
|
398
|
+
if (req.method === "GET" && url.pathname === "/api/stop-continue") return guard(handleStopContinueRead(req, res), res);
|
|
399
|
+
if (req.method === "POST" && url.pathname === "/api/stop-continue") return guard(handleStopContinue(req, res), res);
|
|
392
400
|
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|
|
393
401
|
if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
|
|
394
402
|
if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
|
|
@@ -408,6 +416,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
408
416
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
409
417
|
return guard(writeJsonArray(res, eventsSince(url.searchParams.get("since") ?? 0)), res);
|
|
410
418
|
}
|
|
419
|
+
// GET /api/sessions — which sessions this deck is watching and which of
|
|
420
|
+
// them waits on a human, without the ring itself. See session-summary.mjs.
|
|
421
|
+
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
422
|
+
return send(res, 200, { ok: true, now: Date.now(), sessions: summarizeSessions(eventsSince(0)) });
|
|
423
|
+
}
|
|
411
424
|
|
|
412
425
|
// GET /api/clear — what a POST to this path would do, and to whose log.
|
|
413
426
|
// See handleClearPreview.
|
|
@@ -514,7 +527,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
514
527
|
void trafficCapture.resume(boundPort).catch(() => {});
|
|
515
528
|
server.once("close", () => trafficCapture.dispose());
|
|
516
529
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
517
|
-
if (codex)
|
|
530
|
+
if (codex) {
|
|
531
|
+
startCodexWatcher(workspace);
|
|
532
|
+
server.once("close", stopCodexEnrichment);
|
|
533
|
+
}
|
|
518
534
|
if (claude) {
|
|
519
535
|
const recovery = createClaudeRecovery({ workspace });
|
|
520
536
|
const recover = () => { void recovery.scan().catch(() => {}); };
|
package/src/server/installer.mjs
CHANGED
|
@@ -6,16 +6,21 @@
|
|
|
6
6
|
// running server still sees both CLIs. Re-runs are safe; entries are tagged
|
|
7
7
|
// with __agent-dag and de-duped.
|
|
8
8
|
import { hookRuntime } from "./app-host.mjs";
|
|
9
|
-
import { readFile, mkdir } from "node:fs/promises";
|
|
9
|
+
import { readFile, mkdir, unlink } from "node:fs/promises";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { join, resolve, dirname } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { claudeCodeVersions, claudeConfigDir, claudeVersionAtLeast } from "./claude-dir.mjs";
|
|
14
|
+
// The first Claude Code that knows StopFailure — see stop-failure.mjs.
|
|
15
|
+
import { STOP_FAILURE, STOP_FAILURE_SINCE } from "./stop-failure.mjs";
|
|
14
16
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
17
|
import { shellQuoteArg } from "./exec.mjs";
|
|
16
18
|
// The read-before-rewrite and the atomic replace every settings writer goes
|
|
17
19
|
// through — see atomic-write.mjs.
|
|
18
|
-
import { readSettingsForWrite, writeFileAtomic } from "./atomic-write.mjs";
|
|
20
|
+
import { readSettingsForWrite, writeFileAtomic, createTemp, renameWithRetry } from "./atomic-write.mjs";
|
|
21
|
+
// Skill activity's typed-command entry, its opt-in and its version gate — see
|
|
22
|
+
// skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
+
import { USER_PROMPT_EXPANSION, skillHookDecision } from "./skill-hook.mjs";
|
|
19
24
|
// The discovery dir every deck registers in and every hook reads — see
|
|
20
25
|
// discovery.mjs, which keeps this deck's record there.
|
|
21
26
|
import { AGENT_DAG_DIR } from "./discovery.mjs";
|
|
@@ -45,11 +50,46 @@ const CLAUDE_EVENTS = [
|
|
|
45
50
|
"Notification",
|
|
46
51
|
];
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The events Claude Code added after the ones above, each beside the first
|
|
55
|
+
* version that knows it — added only where EVERY Claude Code the deck finds on
|
|
56
|
+
* the machine is at least that version.
|
|
57
|
+
*
|
|
58
|
+
* Not an event the older CLI would merely ignore. Until 2.1.101 an event name
|
|
59
|
+
* Claude Code did not recognise made it ignore the whole settings.json: the
|
|
60
|
+
* user's permissions, env and hooks, and every one of ours with them (Claude
|
|
61
|
+
* Code's changelog, 2.1.101). StopFailure arrived in 2.1.78, so a machine still
|
|
62
|
+
* on anything before that would lose its settings to the entry meant to tell it
|
|
63
|
+
* more.
|
|
64
|
+
*
|
|
65
|
+
* So the version is read off disk (claudeCodeVersions — never a `claude
|
|
66
|
+
* --version`, which is seconds at boot), and a machine where the deck can find
|
|
67
|
+
* no version gets none of these: the deck it had before, and a line in the
|
|
68
|
+
* README saying why. Every boot reinstalls, so a Claude Code updated past the
|
|
69
|
+
* gate picks the entry up on the deck's next start — and one rolled back below
|
|
70
|
+
* it has the entry taken back out, the same way.
|
|
71
|
+
*/
|
|
72
|
+
const VERSIONED_CLAUDE_EVENTS = [
|
|
73
|
+
{ event: STOP_FAILURE, since: STOP_FAILURE_SINCE },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Which of `versioned` this machine's Claude Code knows: those whose `since`
|
|
77
|
+
* every found version is at least, and none when no version was found. */
|
|
78
|
+
function eventsKnownTo(versions, versioned = VERSIONED_CLAUDE_EVENTS) {
|
|
79
|
+
const found = Array.isArray(versions) ? versions : [];
|
|
80
|
+
if (found.length === 0) return [];
|
|
81
|
+
return versioned.filter(v => found.every(ver => claudeVersionAtLeast(ver, v.since))).map(v => v.event);
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
const PROVIDERS = {
|
|
49
85
|
claude: {
|
|
50
86
|
settingsPath: join(CLAUDE_DIR, "settings.json"),
|
|
51
87
|
hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
|
|
52
88
|
events: CLAUDE_EVENTS,
|
|
89
|
+
versioned: VERSIONED_CLAUDE_EVENTS,
|
|
90
|
+
// UserPromptExpansion, for Skill activity (#2181): opt-in, and gated on
|
|
91
|
+
// every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
92
|
+
skillHook: true,
|
|
53
93
|
ensureDir: CLAUDE_DIR,
|
|
54
94
|
},
|
|
55
95
|
// Uninstall-only. Codex hooks do not fire reliably on Windows, so the deck
|
|
@@ -336,8 +376,82 @@ async function soundHookRetirement(provider, cfg, current) {
|
|
|
336
376
|
return { retire, completeSoundHookRetirement };
|
|
337
377
|
}
|
|
338
378
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Where the settings.json bytes from before the UserPromptExpansion entry was
|
|
381
|
+
* first added are kept: beside hook.js, in the directory that is ours. Written
|
|
382
|
+
* once per enabling — the pass that adds the entry where none of ours was —
|
|
383
|
+
* with mode 0600, because settings.json can hold API keys in its `env`.
|
|
384
|
+
* `--uninstall` leaves it, as it leaves hook.js: it is the user's own data.
|
|
385
|
+
*/
|
|
386
|
+
export const SKILL_HOOK_BACKUP = "settings.before-skill-hook.json";
|
|
387
|
+
|
|
388
|
+
/** Whether a group list holds a forwarder of ours. */
|
|
389
|
+
function holdsOurs(groups) {
|
|
390
|
+
return Array.isArray(groups) && groups.some(isOurEntry);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Whether the entry can be added without guessing: `hooks` absent or a plain
|
|
394
|
+
* object, and any UserPromptExpansion already there a list. Anything else is
|
|
395
|
+
* a file shaped in a way this deck did not write and Claude Code may not read,
|
|
396
|
+
* and adding to it is not a decision to take on the user's behalf. */
|
|
397
|
+
function skillHookFits(settings) {
|
|
398
|
+
const hooks = settings.hooks;
|
|
399
|
+
if (hooks === undefined) return true;
|
|
400
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
401
|
+
const now = hooks[USER_PROMPT_EXPANSION];
|
|
402
|
+
return now === undefined || Array.isArray(now);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The backup, through a temp file and a rename like every settings write,
|
|
406
|
+
* created 0600 and kept 0600. */
|
|
407
|
+
async function writeBackup(path, text) {
|
|
408
|
+
const { tmp, handle } = await createTemp(path, { mode: 0o600 });
|
|
409
|
+
try {
|
|
410
|
+
try {
|
|
411
|
+
await handle.writeFile(text, "utf8");
|
|
412
|
+
await handle.sync();
|
|
413
|
+
} finally {
|
|
414
|
+
await handle.close();
|
|
415
|
+
}
|
|
416
|
+
await renameWithRetry(tmp, path);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
await unlink(tmp).catch(() => {});
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether this pass adds Skill activity's entry: the gate's decision
|
|
425
|
+
* (skill-hook.mjs), then two refusals of the installer's own — a settings file
|
|
426
|
+
* whose hooks are not in a shape to add to, and a backup that could not be
|
|
427
|
+
* written. Either leaves the entry out; neither stops the rest of the install.
|
|
428
|
+
* `options` is the suite's: skillHookDecision's options, in place of this
|
|
429
|
+
* machine's environment and disk.
|
|
430
|
+
*/
|
|
431
|
+
async function decideSkillHook(options, settings, before, backupPath) {
|
|
432
|
+
const decision = await skillHookDecision(options ?? {});
|
|
433
|
+
if (!decision.install) return decision;
|
|
434
|
+
if (!skillHookFits(settings)) return { ...decision, install: false, state: "skipped", reason: "settings-shape" };
|
|
435
|
+
// The bytes the write is about to replace, kept before anything is changed,
|
|
436
|
+
// the first time the entry goes in. Nothing to keep when there was no file.
|
|
437
|
+
if (before !== null && !holdsOurs(settings.hooks?.[USER_PROMPT_EXPANSION])) {
|
|
438
|
+
try {
|
|
439
|
+
await writeBackup(backupPath, before);
|
|
440
|
+
} catch {
|
|
441
|
+
return { ...decision, install: false, state: "skipped", reason: "backup-failed" };
|
|
442
|
+
}
|
|
443
|
+
return { ...decision, backup: backupPath };
|
|
444
|
+
}
|
|
445
|
+
return decision;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}.
|
|
449
|
+
* `claudeVersions` is the suite's: the Claude Code versions to gate the
|
|
450
|
+
* versioned events on, in place of the ones on this machine's disk.
|
|
451
|
+
* `skillHook` is the suite's too: skillHookDecision's options (the opt-in,
|
|
452
|
+
* the installs found, the probe), in place of this machine's. The answer
|
|
453
|
+
* carries the decision as `skillHook` for the claude provider. */
|
|
454
|
+
export async function installHooks({ provider = "claude", beforeWrite = null, claudeVersions = null, skillHook = null } = {}) {
|
|
341
455
|
const cfg = PROVIDERS[provider];
|
|
342
456
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
343
457
|
// An uninstall-only provider has no event list. Saying so beats the
|
|
@@ -357,13 +471,38 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
357
471
|
// Discovery dir is shared across providers — always make sure it exists.
|
|
358
472
|
await ensureDir(AGENT_DAG_DIR);
|
|
359
473
|
|
|
474
|
+
// Decided before `hooks` is touched, so the shape check sees the file as read.
|
|
475
|
+
const skill = cfg.skillHook
|
|
476
|
+
? await decideSkillHook(skillHook, current, before, join(cfg.hookInstallDir, SKILL_HOOK_BACKUP))
|
|
477
|
+
: null;
|
|
478
|
+
|
|
360
479
|
current.hooks = current.hooks ?? {};
|
|
361
480
|
|
|
362
|
-
|
|
481
|
+
const versioned = cfg.versioned ?? [];
|
|
482
|
+
const known = versioned.length ? eventsKnownTo(claudeVersions ?? claudeCodeVersions(), versioned) : [];
|
|
483
|
+
if (skill?.install) known.push(USER_PROMPT_EXPANSION);
|
|
484
|
+
// Every event whose entry is ours only while a gate says so.
|
|
485
|
+
const gated = [...versioned.map(v => v.event), ...(skill ? [USER_PROMPT_EXPANSION] : [])];
|
|
486
|
+
const events = [...cfg.events, ...known];
|
|
487
|
+
for (const evt of events) {
|
|
363
488
|
const cleaned = dedupeOurEntries(current.hooks[evt]);
|
|
364
489
|
cleaned.push(buildHookEntry(command));
|
|
365
490
|
current.hooks[evt] = cleaned;
|
|
366
491
|
}
|
|
492
|
+
// A gated event this machine's Claude Code does not know — or, for Skill
|
|
493
|
+
// activity's, one nobody asked for — loses our entry, and the key goes with
|
|
494
|
+
// it when nothing of the user's is left under it: that key is the thing an
|
|
495
|
+
// older Claude Code cannot read.
|
|
496
|
+
// A value under that key that is not a list, or a list with nothing of ours
|
|
497
|
+
// in it, is left exactly as it is: it is not this deck's to delete.
|
|
498
|
+
for (const event of gated) {
|
|
499
|
+
const was = current.hooks[event];
|
|
500
|
+
if (known.includes(event) || !Array.isArray(was)) continue;
|
|
501
|
+
const cleaned = dedupeOurEntries(was);
|
|
502
|
+
if (cleaned.length === was.length && cleaned.every((g, i) => g === was[i])) continue;
|
|
503
|
+
if (cleaned.length === 0) delete current.hooks[event];
|
|
504
|
+
else current.hooks[event] = cleaned;
|
|
505
|
+
}
|
|
367
506
|
|
|
368
507
|
// Retiring the finish-sound hook rides in here, on this read and this write,
|
|
369
508
|
// and this is the seam it needs rather than a convenient one. #704 moved the
|
|
@@ -421,8 +560,10 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
421
560
|
// and from the park that was its only other copy.
|
|
422
561
|
if (!(await unchangedSince(cfg.settingsPath, before))) {
|
|
423
562
|
return {
|
|
424
|
-
settingsPath: cfg.settingsPath, hookPath, events
|
|
563
|
+
settingsPath: cfg.settingsPath, hookPath, events, provider,
|
|
425
564
|
changed: false, raced: true, retire: { ...retire, pending: false },
|
|
565
|
+
// Nothing was written, so an entry decided on is not on disk.
|
|
566
|
+
...(skill ? { skillHook: skill.install ? { ...skill, install: false, state: "skipped", reason: "raced" } : skill } : {}),
|
|
426
567
|
};
|
|
427
568
|
}
|
|
428
569
|
await writeFileAtomic(cfg.settingsPath, next);
|
|
@@ -431,7 +572,7 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
431
572
|
// is what a live session's cached command still names until the new entry is
|
|
432
573
|
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
433
574
|
if (retire.pending) await completeSoundHookRetirement(retire, current);
|
|
434
|
-
return { settingsPath: cfg.settingsPath, hookPath, events
|
|
575
|
+
return { settingsPath: cfg.settingsPath, hookPath, events, provider, changed, retire, ...(skill ? { skillHook: skill } : {}) };
|
|
435
576
|
}
|
|
436
577
|
|
|
437
578
|
/**
|
|
@@ -135,19 +135,52 @@ export function slotFor(accounts, key) {
|
|
|
135
135
|
* manifest entirely — not listed as withheld, which would tell the group that
|
|
136
136
|
* an account exists and is being kept back, and that is itself the fact being
|
|
137
137
|
* kept back.
|
|
138
|
+
*
|
|
139
|
+
* THE ONE THIS DECK IS ON IS LISTED AND NOT GIVEN, unless `sendInUse` says to
|
|
140
|
+
* (#2192). A Claude login renews once: the copy that renews it first keeps it,
|
|
141
|
+
* and every other copy is refused from then on — so a peer handed the login
|
|
142
|
+
* this deck is signed in to holds the same expiry, reaches for the same renewal,
|
|
143
|
+
* and one of the two machines is signed out ("Login expired · Please run
|
|
144
|
+
* /login"). The row stays in the list, because it is what `current` names and a
|
|
145
|
+
* paired deck keeps a `current` only for an account in the same list (see
|
|
146
|
+
* heardCurrent) — which is how the other deck knows this one is on it. It goes
|
|
147
|
+
* as `shareable: false`, which every peer since 3.29.0 already reads as "do not
|
|
148
|
+
* ask", and `inUse`, which a peer of this version reads as why. An older peer
|
|
149
|
+
* that asks anyway is refused by `serve` (lan-serve.mjs) with "in use".
|
|
150
|
+
*
|
|
151
|
+
* NOT LISTED AT ALL WHILE ITS OWNER HIDES WHICH ACCOUNT THIS DECK IS ON
|
|
152
|
+
* (`shareActive` false). Then `current` names nothing, so the row has no reason
|
|
153
|
+
* to be there, and it would say exactly what the switch keeps back: `inUse`
|
|
154
|
+
* names it, and even a bare `shareable: false` on the one ticked login that
|
|
155
|
+
* works here points at it. Left out, it reads as a login not ticked — the
|
|
156
|
+
* fact this list already keeps back (see `shared` above) — and it is still
|
|
157
|
+
* never sent: nobody asks for a login that is not listed, and `serve` refuses
|
|
158
|
+
* one that is asked for anyway, in the words it refuses an unticked one.
|
|
138
159
|
*/
|
|
139
|
-
export function manifestFor(accounts, shared) {
|
|
160
|
+
export function manifestFor(accounts, shared, { sendInUse = false, shareActive } = {}) {
|
|
140
161
|
const want = new Set(shared);
|
|
141
|
-
|
|
162
|
+
// Which identity is live here, whichever of its slots onePerKey picks: two
|
|
163
|
+
// slots for one login are one login.
|
|
164
|
+
const live = new Set(sendInUse ? [] : accounts.filter(a => a.active).map(a => a.key));
|
|
165
|
+
const hidden = shareActive === false;
|
|
166
|
+
return onePerKey(accounts.filter(a => want.has(a.key) && !(hidden && live.has(a.key))))
|
|
142
167
|
.map(a => ({
|
|
143
168
|
key: a.key,
|
|
144
169
|
email: a.email,
|
|
145
170
|
alive: !!a.alive,
|
|
146
|
-
...(a.readable === false ? { shareable: false } : {}),
|
|
171
|
+
...(a.readable === false || live.has(a.key) ? { shareable: false } : {}),
|
|
172
|
+
...(live.has(a.key) ? { inUse: true } : {}),
|
|
147
173
|
}))
|
|
148
174
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
149
175
|
}
|
|
150
176
|
|
|
177
|
+
/** Whether a peer that asks may be handed this identity now: every one but the
|
|
178
|
+
* one this deck is on, unless the owner chose to send that too — the same
|
|
179
|
+
* rule manifestFor lists by, checked again at the moment of sending. */
|
|
180
|
+
export function mayGiveInUse(accounts, key, sendInUse) {
|
|
181
|
+
return sendInUse === true || !accounts.some(a => a.key === key && a.active);
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/**
|
|
152
185
|
* Which account this deck is on, as a frame field for the decks it is paired
|
|
153
186
|
* with — or nothing.
|
|
@@ -201,6 +234,9 @@ export function offered(list) {
|
|
|
201
234
|
alive: storedCopyAlive(a.alive, a.collector),
|
|
202
235
|
// Additive wire field: an older peer omitted it, which means shareable.
|
|
203
236
|
...(a.shareable === false ? { shareable: false } : {}),
|
|
237
|
+
// Additive too (#2192): that deck is on this login and keeps it there.
|
|
238
|
+
// Only beside `shareable: false`, which is what stops a round asking.
|
|
239
|
+
...(a.shareable === false && a.inUse === true ? { inUse: true } : {}),
|
|
204
240
|
}))
|
|
205
241
|
.filter(a => a.key && a.email))
|
|
206
242
|
// Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
|
|
@@ -238,8 +274,10 @@ export const SENDER_UNREADABLE = "keychain_unavailable";
|
|
|
238
274
|
|
|
239
275
|
/** The reasons `serve` gives for a login it will not send. Its other answer to
|
|
240
276
|
* a `want`, "not paired", is not in the set, so a deck that gets it records
|
|
241
|
-
* "refused".
|
|
242
|
-
|
|
277
|
+
* "refused". "in use" is the sending deck being on that login itself (#2192);
|
|
278
|
+
* a deck older than that records it as "refused", which is all it proves. */
|
|
279
|
+
export const IN_USE = "in use";
|
|
280
|
+
const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, IN_USE, "error"]);
|
|
243
281
|
|
|
244
282
|
/**
|
|
245
283
|
* A peer's refusal, as this deck records it. The panel prints these, so a peer
|