ccdeck 3.41.3 → 3.43.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 +38 -18
- package/bin/deck.js +4 -0
- package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
- package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
- package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
- package/dist/web/assets/app-9p127oSR.js +310 -0
- package/dist/web/assets/app-DsHSufBt.css +1 -0
- package/dist/web/assets/index-Bkx27-wy.js +1 -0
- package/dist/web/index.html +18 -4
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-choice.mjs +198 -0
- package/src/server/account-notify.mjs +15 -8
- package/src/server/account-routes.mjs +6 -0
- package/src/server/activity.mjs +11 -3
- package/src/server/agent-activity.mjs +33 -10
- package/src/server/browser-watch-decks.mjs +3 -2
- package/src/server/browser-watch.mjs +3 -4
- package/src/server/claude-recovery.mjs +150 -0
- package/src/server/codex-enrichment.mjs +11 -3
- package/src/server/codex-lineage.mjs +29 -0
- package/src/server/codex-recovery.mjs +83 -0
- package/src/server/codex-translate.mjs +24 -0
- package/src/server/codex-watch.mjs +116 -12
- package/src/server/cswap-auto-choice.mjs +296 -0
- package/src/server/cswap-auto-loop.mjs +18 -2
- package/src/server/cswap-auto-readers.mjs +5 -2
- package/src/server/cswap-auto.mjs +7 -1
- package/src/server/deck-prefs.mjs +5 -1
- package/src/server/event-pipeline.mjs +6 -3
- package/src/server/evicted-enrichment.mjs +2 -0
- package/src/server/feedback-form.mjs +1 -0
- package/src/server/index.mjs +13 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/reports-routes.mjs +31 -12
- package/src/server/request-gates.mjs +2 -0
- package/src/server/tool-audit-operations.mjs +112 -0
- package/src/server/tool-audit-report.mjs +115 -0
- package/src/server/tool-audit-routes.mjs +38 -0
- package/src/server/tool-audit.mjs +126 -0
- package/src/server/traffic-radar-config.mjs +8 -1
- package/dist/web/assets/BrowserWatchModal-6TctCbc4.js +0 -1
- package/dist/web/assets/RadioPanel-ZDDwNYnL.js +0 -1
- package/dist/web/assets/TrafficRadar-lfG5ekUR.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
- package/dist/web/assets/index-BgSnAnP6.css +0 -1
- package/dist/web/assets/index-C80Hp_2s.js +0 -307
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// The deck's own step in an auto-switch tick, taken before claude-swap's
|
|
2
|
+
// engine: the home account and "soonest weekly reset" rules
|
|
3
|
+
// (account-choice.mjs), carried out, and what they need remembered between
|
|
4
|
+
// ticks.
|
|
5
|
+
//
|
|
6
|
+
// NOTHING SET, NOTHING DONE. With the next account left on "most room" and no
|
|
7
|
+
// home, a tick never reads anything here beyond this file and goes straight to
|
|
8
|
+
// the engine — the behaviour every deck had before these rules existed.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS REMEMBERED, per machine, in auto-choice.json beside prefs.json:
|
|
11
|
+
//
|
|
12
|
+
// next "room" | "weekly"
|
|
13
|
+
// home { key, email, orgUuid } or null — an identity, never a slot:
|
|
14
|
+
// slots are reused and differ between machines (lan-copies.mjs)
|
|
15
|
+
// manual the account in use was picked by hand: the panel's Switch, the
|
|
16
|
+
// terminal, anything that was not a tick. The moves nobody needs
|
|
17
|
+
// leave it alone until it fills.
|
|
18
|
+
// goHome a home was just set, so the next tick may take the user there
|
|
19
|
+
// lastActive the account the last tick left the user on, which is how a
|
|
20
|
+
// switch made by hand is told from one a tick made
|
|
21
|
+
// lastMoveAt when a tick last moved the account, for the cooldowns
|
|
22
|
+
//
|
|
23
|
+
// The ticks run one at a time under the store mutex; the panel's two writes
|
|
24
|
+
// do not, so every write here goes through one chain.
|
|
25
|
+
import { readFile, readdir, mkdir } from "node:fs/promises";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
28
|
+
import { writeFileAtomic } from "./atomic-write.mjs";
|
|
29
|
+
import { backupRoot, fetchClaudeAccounts, slotNumber, switchClaudeAccount } from "./claude-accounts.mjs";
|
|
30
|
+
import { readCswapConfig } from "./cswap-auto-readers.mjs";
|
|
31
|
+
import { accountKey } from "./lan-copies.mjs";
|
|
32
|
+
import { isProcessAlive } from "./deck-probe.mjs";
|
|
33
|
+
import { chooseAccount, countedModels, NEXT_CHOICES } from "./account-choice.mjs";
|
|
34
|
+
|
|
35
|
+
/** Where the choice is kept: beside prefs.json, in the deck's own data. */
|
|
36
|
+
export function choicePath(home = deckDataDir()) {
|
|
37
|
+
return join(home, "auto-choice.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const text = v => (typeof v === "string" && v ? v : null);
|
|
41
|
+
|
|
42
|
+
/** Whatever is on disk, as a whole and known-shaped choice. */
|
|
43
|
+
export function normaliseChoice(raw) {
|
|
44
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
45
|
+
const home = src.home && typeof src.home === "object" && text(src.home.email)
|
|
46
|
+
? { key: accountKey(src.home.email, src.home.orgUuid), email: src.home.email, orgUuid: text(src.home.orgUuid) }
|
|
47
|
+
: null;
|
|
48
|
+
return {
|
|
49
|
+
next: NEXT_CHOICES.includes(src.next) ? src.next : "room",
|
|
50
|
+
home,
|
|
51
|
+
manual: src.manual === true,
|
|
52
|
+
goHome: home != null && src.goHome === true,
|
|
53
|
+
lastActive: text(src.lastActive),
|
|
54
|
+
lastMoveAt: Number.isFinite(src.lastMoveAt) ? src.lastMoveAt : 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The choice as last read or written. This process is the file's only writer,
|
|
59
|
+
// so after the first read a tick asks memory, not the disk — which is what
|
|
60
|
+
// keeps a deck with nothing set paying nothing per tick.
|
|
61
|
+
let _held = null;
|
|
62
|
+
|
|
63
|
+
async function readChoice() {
|
|
64
|
+
if (_held) return _held;
|
|
65
|
+
try { _held = normaliseChoice(JSON.parse(await readFile(choicePath(), "utf8"))); } catch { _held = normaliseChoice(null); }
|
|
66
|
+
return _held;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let _writes = Promise.resolve();
|
|
70
|
+
/** Read, change and write the choice, one change at a time. A change that
|
|
71
|
+
* changes nothing writes nothing: most ticks only confirm where they are. */
|
|
72
|
+
function updateChoice(change) {
|
|
73
|
+
const done = _writes.then(async () => {
|
|
74
|
+
const before = await readChoice();
|
|
75
|
+
const next = normaliseChoice(change(before));
|
|
76
|
+
if (JSON.stringify(next) === JSON.stringify(before)) return before;
|
|
77
|
+
const path = choicePath();
|
|
78
|
+
await mkdir(dirname(path), { recursive: true });
|
|
79
|
+
await writeFileAtomic(path, `${JSON.stringify(next)}\n`);
|
|
80
|
+
_held = next;
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
_writes = done.catch(() => {});
|
|
84
|
+
return done;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What the panel draws: the strategy, and whose row carries the home mark. */
|
|
88
|
+
export async function choiceStatus() {
|
|
89
|
+
const c = await readChoice();
|
|
90
|
+
return { next: c.next, home: c.home ? { email: c.home.email, orgUuid: c.home.orgUuid } : null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "room" or "weekly". Choosing is a decision about the account in use too,
|
|
94
|
+
* so a pick made by hand no longer holds the new rule back. */
|
|
95
|
+
export async function setNextChoice(value) {
|
|
96
|
+
if (!NEXT_CHOICES.includes(value)) return { ok: false, reason: "bad_value" };
|
|
97
|
+
await updateChoice(c => ({ ...c, next: value, manual: false, lastActive: null }));
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make the account in slot `accountNum` the home, or clear it with null.
|
|
103
|
+
* Setting one is asking to be taken there: the next tick does, once the
|
|
104
|
+
* account is usable, even from an account picked by hand.
|
|
105
|
+
*/
|
|
106
|
+
export async function setHomeAccount(accountNum) {
|
|
107
|
+
if (accountNum === null) {
|
|
108
|
+
await updateChoice(c => ({ ...c, home: null, goHome: false }));
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
const num = slotNumber(accountNum);
|
|
112
|
+
if (num == null) return { ok: false, reason: "bad_account" };
|
|
113
|
+
const roster = await fetchClaudeAccounts();
|
|
114
|
+
const row = roster?.ok ? roster.accounts?.find(a => a.num === num) : null;
|
|
115
|
+
if (!row?.email) return { ok: false, reason: "bad_account" };
|
|
116
|
+
await updateChoice(c => ({ ...c, home: { email: row.email, orgUuid: row.orgUuid ?? null }, goHome: true, manual: false }));
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Auto-switch was just turned on: whatever happened while it was off is not
|
|
121
|
+
* a pick to protect, so the next tick starts from the account in use. */
|
|
122
|
+
export function forgetPicksByHand() {
|
|
123
|
+
return updateChoice(c => ({ ...c, manual: false, lastActive: null })).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What claude-swap's engine keeps in autoswitch_state.json that a move here
|
|
128
|
+
* has to respect: when it last switched, in ms or 0 — a move the deck makes
|
|
129
|
+
* does not start the engine's cooldown, so this one keeps both — and the
|
|
130
|
+
* accounts it has quarantined, by key.
|
|
131
|
+
*
|
|
132
|
+
* A quarantine is the slot's, with the email it held: the engine drops one
|
|
133
|
+
* whose slot now holds somebody else, so it is only this account's while the
|
|
134
|
+
* emails agree. One the engine would drop for a fresh login it drops on its
|
|
135
|
+
* next run, which every tick that does not move here is.
|
|
136
|
+
*/
|
|
137
|
+
async function engineState(accounts) {
|
|
138
|
+
let state = null;
|
|
139
|
+
try { state = JSON.parse(await readFile(join(backupRoot(), "autoswitch_state.json"), "utf8")); } catch {}
|
|
140
|
+
const at = Number(state?.lastSwitchAt);
|
|
141
|
+
const held = state?.quarantine && typeof state.quarantine === "object" ? state.quarantine : {};
|
|
142
|
+
const sameEmail = (x, y) => typeof x === "string" && typeof y === "string" && x.trim().toLowerCase() === y.trim().toLowerCase();
|
|
143
|
+
return {
|
|
144
|
+
lastSwitchAt: Number.isFinite(at) && at > 0 ? at * 1000 : 0,
|
|
145
|
+
quarantined: new Set(accounts.filter(a => sameEmail(held[String(a.num)]?.email, a.email)).map(a => a.key)),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The accounts the decks this one is paired with say they are working on.
|
|
150
|
+
* One login live on two machines races for every renewal (README, "One login
|
|
151
|
+
* live in one place"), so a tick never moves onto one of them. */
|
|
152
|
+
async function accountsOnOtherDecks() {
|
|
153
|
+
try {
|
|
154
|
+
const { lanEngine } = await import("./lan-deck.mjs");
|
|
155
|
+
const peers = lanEngine.status()?.peers ?? [];
|
|
156
|
+
return new Set(peers.map(p => p?.offers?.current?.key).filter(k => typeof k === "string"));
|
|
157
|
+
} catch {
|
|
158
|
+
return new Set();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The name claude-swap gives an account's `cswap run` profile after its
|
|
163
|
+
* slot number: the email, NFC, with anything but ASCII letters, digits and
|
|
164
|
+
* `._-` turned into `_` (claude_swap/session.py, slugify_email). */
|
|
165
|
+
export function sessionSlug(email) {
|
|
166
|
+
return [...String(email ?? "").normalize("NFC")].map(ch => (/^[A-Za-z0-9._-]$/.test(ch) ? ch : "_")).join("");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The accounts a `cswap run` session is working on, by key. Such a session
|
|
171
|
+
* runs Claude Code on the account's own copy of the login; the engine never
|
|
172
|
+
* activates one of them, because two copies of one login race for every
|
|
173
|
+
* renewal. `cswap switch`, which is what a move here runs, only warns — so
|
|
174
|
+
* the deck asks the same question first.
|
|
175
|
+
*
|
|
176
|
+
* Claude Code leaves one record per running instance in the profile's
|
|
177
|
+
* `sessions/<pid>.json`; one counts while its process is running. A pid used
|
|
178
|
+
* again by something else reads as live too, which only leaves that move to
|
|
179
|
+
* the engine.
|
|
180
|
+
*/
|
|
181
|
+
async function accountsInCswapRun(accounts) {
|
|
182
|
+
const live = new Set();
|
|
183
|
+
for (const a of accounts) {
|
|
184
|
+
if (a.num == null || !a.email) continue;
|
|
185
|
+
const dir = join(backupRoot(), "sessions", `${a.num}-${sessionSlug(a.email)}`, "sessions");
|
|
186
|
+
let names = [];
|
|
187
|
+
try { names = (await readdir(dir)).filter(n => /^\d+\.json$/.test(n)); } catch { continue; }
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
let pid = null;
|
|
190
|
+
try { pid = Number(JSON.parse(await readFile(join(dir, name), "utf8"))?.pid); } catch {}
|
|
191
|
+
if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { live.add(a.key); break; }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return live;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const numberOr = (v, fallback) => {
|
|
198
|
+
const n = Number(v);
|
|
199
|
+
return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** One side of a move, in the shape claude-swap's own `switch` event has. */
|
|
203
|
+
const refOf = a => (a ? { number: a.num, email: a.email ?? null } : null);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ask the two rules, and move when they say so. Under the store mutex, from
|
|
207
|
+
* the tick (cswap-auto-loop.mjs).
|
|
208
|
+
*
|
|
209
|
+
* Returns `{ result, accounts }`: `result` is a tick in the shape summarise
|
|
210
|
+
* gives the engine's — so the caches, the swap log and the notification treat
|
|
211
|
+
* the move as any other — or null for "run the engine"; `accounts` is the
|
|
212
|
+
* roster it decided on, which noteEngineTick needs to name where the engine
|
|
213
|
+
* went, and null when nothing is set.
|
|
214
|
+
*/
|
|
215
|
+
export async function chooseBeforeEngine({ now = Date.now() } = {}) {
|
|
216
|
+
const choice = await readChoice();
|
|
217
|
+
if (choice.next === "room" && !choice.home) return { result: null, accounts: null };
|
|
218
|
+
const roster = await fetchClaudeAccounts();
|
|
219
|
+
if (!roster?.ok || !Array.isArray(roster.accounts)) return { result: null, accounts: null };
|
|
220
|
+
const accounts = roster.accounts.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
|
|
221
|
+
const active = accounts.find(a => a.active) ?? null;
|
|
222
|
+
|
|
223
|
+
const byHand = active != null && choice.lastActive != null && choice.lastActive !== active.key;
|
|
224
|
+
const manual = choice.manual || byHand;
|
|
225
|
+
const goHome = choice.goHome && active?.key !== choice.home?.key;
|
|
226
|
+
// WHAT THIS TICK LEARNED, WRITTEN OVER WHAT IS THERE NOW. The panel's writes
|
|
227
|
+
// do not wait for the store lock, so "Set as home" or a new rule can land
|
|
228
|
+
// while the awaits below run. `manual` and `goHome` were worked out from the
|
|
229
|
+
// choice as it was read, so they are only written back over that same
|
|
230
|
+
// choice — over a newer one they would undo what the person just set.
|
|
231
|
+
// Every write replaces the held object, which is what tells the two apart.
|
|
232
|
+
const keep = c => (c === choice
|
|
233
|
+
? { ...c, manual, goHome, lastActive: active?.key ?? c.lastActive }
|
|
234
|
+
: { ...c, lastActive: active?.key ?? c.lastActive });
|
|
235
|
+
|
|
236
|
+
const cfg = (await readCswapConfig()) ?? {};
|
|
237
|
+
const threshold = numberOr(cfg["autoswitch.threshold"]?.value, 90);
|
|
238
|
+
const models = countedModels(cfg["autoswitch.model"]?.value);
|
|
239
|
+
const engine = await engineState(accounts);
|
|
240
|
+
const pick = chooseAccount({
|
|
241
|
+
accounts,
|
|
242
|
+
choice: { next: choice.next, home: choice.home?.key ?? null, manual, goHome },
|
|
243
|
+
threshold,
|
|
244
|
+
models,
|
|
245
|
+
busy: new Set([...await accountsOnOtherDecks(), ...await accountsInCswapRun(accounts)]),
|
|
246
|
+
quarantined: engine.quarantined,
|
|
247
|
+
sinceMove: now - Math.max(choice.lastMoveAt, engine.lastSwitchAt),
|
|
248
|
+
cooldownMs: numberOr(cfg["autoswitch.cooldownSeconds"]?.value, 300) * 1000,
|
|
249
|
+
optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
|
|
250
|
+
now,
|
|
251
|
+
});
|
|
252
|
+
if (!pick) {
|
|
253
|
+
await updateChoice(keep);
|
|
254
|
+
return { result: null, accounts };
|
|
255
|
+
}
|
|
256
|
+
const moved = await switchClaudeAccount(pick.to.num);
|
|
257
|
+
if (!moved.ok) {
|
|
258
|
+
await updateChoice(keep);
|
|
259
|
+
return { result: null, accounts };
|
|
260
|
+
}
|
|
261
|
+
// The move happened, so where it left the user and when are true whatever
|
|
262
|
+
// was set meanwhile; a home still to be gone to is the current choice's.
|
|
263
|
+
await updateChoice(c => ({
|
|
264
|
+
...c, manual: false, goHome: (c === choice ? goHome : c.goHome) && pick.to.key !== c.home?.key,
|
|
265
|
+
lastActive: pick.to.key, lastMoveAt: now,
|
|
266
|
+
}));
|
|
267
|
+
return {
|
|
268
|
+
result: {
|
|
269
|
+
ok: true,
|
|
270
|
+
event: "switch",
|
|
271
|
+
switched: true,
|
|
272
|
+
trigger: pick.trigger,
|
|
273
|
+
reason: null,
|
|
274
|
+
detail: null,
|
|
275
|
+
from: refOf(active),
|
|
276
|
+
to: refOf(pick.to),
|
|
277
|
+
active: refOf(active),
|
|
278
|
+
threshold,
|
|
279
|
+
// What the notification reads, in the shape claude-swap's poll gives:
|
|
280
|
+
// room per account, and the windows of each by label.
|
|
281
|
+
headroom: Object.fromEntries(accounts.map(a => [String(a.num), a.headroom ?? null])),
|
|
282
|
+
windows: Object.fromEntries(accounts.map(a => [String(a.num),
|
|
283
|
+
Object.fromEntries((a.lanes ?? []).map(l => [l.label, l.pct]))])),
|
|
284
|
+
resetAt: pick.resetAt ?? null,
|
|
285
|
+
},
|
|
286
|
+
accounts,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The engine switched in a tick this step had looked at: the account it left
|
|
291
|
+
* the user on is the tick's pick, not one made by hand. */
|
|
292
|
+
export async function noteEngineTick(result, accounts) {
|
|
293
|
+
if (!accounts || !result?.switched) return;
|
|
294
|
+
const to = accounts.find(a => String(a.num) === String(result.to?.number));
|
|
295
|
+
await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
|
|
296
|
+
}
|
|
@@ -20,6 +20,9 @@ import { withStoreLock } from "./store-lock.mjs";
|
|
|
20
20
|
// moved the account is said once on the desktop (account-notify.mjs). Both
|
|
21
21
|
// doors return at once and never throw.
|
|
22
22
|
import { noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
|
|
23
|
+
// The owner's two rules — a home account, and the soonest weekly reset — asked
|
|
24
|
+
// before the engine, inside the same lock. With neither set, nothing.
|
|
25
|
+
import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick } from "./cswap-auto-choice.mjs";
|
|
23
26
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
24
27
|
import { join } from "node:path";
|
|
25
28
|
import { homedir } from "node:os";
|
|
@@ -51,6 +54,9 @@ function summarise(stdout) {
|
|
|
51
54
|
// `switch` event for a decision it did not carry out, and a false positive
|
|
52
55
|
// here throws away readings that cost a subprocess each.
|
|
53
56
|
switched: events.some(e => e.event === "switch" && e.dryRun !== true),
|
|
57
|
+
// Why the engine moved — "proactive", "at-limit", "failover" — beside the
|
|
58
|
+
// deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs).
|
|
59
|
+
trigger: action?.trigger ?? null,
|
|
54
60
|
reason: action?.reason ?? null,
|
|
55
61
|
detail: action?.detail ?? null,
|
|
56
62
|
from: action?.from ?? null,
|
|
@@ -94,9 +100,15 @@ async function runAutoTick() {
|
|
|
94
100
|
const r = await withStoreLock(async () => {
|
|
95
101
|
if (!_enabled) return { skipped: "disabled" };
|
|
96
102
|
if (await externalAutoRunning()) return { skipped: "external-engine" };
|
|
97
|
-
|
|
103
|
+
// The deck's own rules first. A move they make is the tick; when they have
|
|
104
|
+
// nothing to say — or cannot say it — the engine decides, as it always did.
|
|
105
|
+
const chose = await chooseBeforeEngine().catch(() => ({ result: null, accounts: null }));
|
|
106
|
+
if (chose.result) return { chose: chose.result };
|
|
107
|
+
const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
|
|
108
|
+
return { ...out, accounts: chose.accounts };
|
|
98
109
|
});
|
|
99
110
|
if (r.skipped) return { event: "skipped", reason: r.skipped };
|
|
111
|
+
if (r.chose) return r.chose;
|
|
100
112
|
// A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
|
|
101
113
|
// 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
|
|
102
114
|
// is false for a tick that emitted its `{"event":"poll"}` line and then
|
|
@@ -109,7 +121,10 @@ async function runAutoTick() {
|
|
|
109
121
|
if (!r.ok && !r.stdout) {
|
|
110
122
|
return { ok: false, reason: "tick_failed", detail: failureDetail(r, 300) };
|
|
111
123
|
}
|
|
112
|
-
|
|
124
|
+
const result = { ok: true, ...summarise(r.stdout) };
|
|
125
|
+
// Where the engine left the user is a tick's pick, not one made by hand.
|
|
126
|
+
if (result.switched) await noteEngineTick(result, r.accounts).catch(() => {});
|
|
127
|
+
return result;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
// ── deck-managed loop ──────────────────────────────────────────────────────
|
|
@@ -340,6 +355,7 @@ export function lastTick() {
|
|
|
340
355
|
export async function setAutoEnabled(enabled) {
|
|
341
356
|
_enabled = Boolean(enabled);
|
|
342
357
|
noteAutoSwitch(_enabled);
|
|
358
|
+
if (_enabled) await forgetPicksByHand();
|
|
343
359
|
await saveState({ ...(await loadState()), enabled: _enabled });
|
|
344
360
|
if (_enabled) await startLoop(); else stopLoop();
|
|
345
361
|
return { ok: true, enabled: _enabled };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// it re-exports all five names, which is where the suite reaches them.
|
|
10
10
|
import { run } from "./exec.mjs";
|
|
11
11
|
import { cswapBin, cswapRefused } from "./cswap-install.mjs";
|
|
12
|
+
import { stripAnsi } from "./term.mjs";
|
|
12
13
|
|
|
13
14
|
// ── one reading at a time ──────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -157,7 +158,9 @@ export function invalidateCswapAutoCache() {
|
|
|
157
158
|
* whatever shape it is given, and the interval collapses four fields to one
|
|
158
159
|
* number that is clamped anyway. The parse itself is a regex over
|
|
159
160
|
* human-formatted output from a separate Python tool, on both line-ending
|
|
160
|
-
* conventions
|
|
161
|
+
* conventions, and with or without colour: the tool dims its `(default)` marker
|
|
162
|
+
* whenever the environment asks for colour, and a deck launched from a terminal
|
|
163
|
+
* inherits that terminal's FORCE_COLOR. See cswap-auto-readers.test.ts.
|
|
161
164
|
*
|
|
162
165
|
* One reading at a time and one every CONFIG_MIN_GAP_MS at most; the parse below
|
|
163
166
|
* is what a reading is, and admission control is the wrapper. See throttled.
|
|
@@ -174,7 +177,7 @@ async function readCswapConfigNow() {
|
|
|
174
177
|
const r = await run(bin, ["config"]);
|
|
175
178
|
if (!r.ok) return null;
|
|
176
179
|
const out = {};
|
|
177
|
-
for (const line of r.stdout.split("\n")) {
|
|
180
|
+
for (const line of stripAnsi(r.stdout).split("\n")) {
|
|
178
181
|
const m = line.match(/^(\S+)\s+(.*?)\s*(\(default\))?\s*$/);
|
|
179
182
|
if (!m || !m[1].includes(".")) continue;
|
|
180
183
|
const raw = m[2].trim();
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
commandTokens, externalAutoRunning, invalidateCswapAutoCache, looksLikeAutoLoop, readCswapConfig,
|
|
21
21
|
} from "./cswap-auto-readers.mjs";
|
|
22
22
|
import { initCswapAuto, lastTick, loopEnabled, restartLoop, setAutoEnabled } from "./cswap-auto-loop.mjs";
|
|
23
|
+
import { choiceStatus, setHomeAccount, setNextChoice } from "./cswap-auto-choice.mjs";
|
|
23
24
|
import { invalidateClaudeAccountsCache, slotNumber } from "./claude-accounts.mjs";
|
|
24
25
|
// The one store mutex, which the rotation flag below holds like every other
|
|
25
26
|
// writer of claude-swap's store.
|
|
@@ -135,14 +136,19 @@ export { commandTokens, externalAutoRunning, looksLikeAutoLoop };
|
|
|
135
136
|
// cswap-auto-loop.mjs.
|
|
136
137
|
export { initCswapAuto, setAutoEnabled };
|
|
137
138
|
|
|
139
|
+
// Which account a tick moves to — the home it comes back to and the strategy
|
|
140
|
+
// for the next one — kept by the deck, not by claude-swap: cswap-auto-choice.mjs.
|
|
141
|
+
export { setHomeAccount, setNextChoice };
|
|
142
|
+
|
|
138
143
|
export async function autoStatus() {
|
|
139
|
-
const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
|
|
144
|
+
const [config, external, choice] = await Promise.all([readCswapConfig(), externalAutoRunning(), choiceStatus()]);
|
|
140
145
|
return {
|
|
141
146
|
ok: config != null,
|
|
142
147
|
enabled: loopEnabled(),
|
|
143
148
|
external, // user is running their own `cswap auto`
|
|
144
149
|
lastTick: lastTick(),
|
|
145
150
|
settings: config ?? {},
|
|
151
|
+
choice, // { next: "room"|"weekly", home: {email, orgUuid}|null }
|
|
146
152
|
};
|
|
147
153
|
}
|
|
148
154
|
|
|
@@ -72,6 +72,8 @@ export { prefsPath };
|
|
|
72
72
|
*/
|
|
73
73
|
export const DEFAULTS = Object.freeze({
|
|
74
74
|
notifications: false,
|
|
75
|
+
recoverCodexSessions: true,
|
|
76
|
+
recoverClaudeSessions: true,
|
|
75
77
|
// ACCOUNT NOTIFICATIONS, one switch each (account-notify.mjs). Not part of
|
|
76
78
|
// `notifications` above, which is about a deck nobody is looking at: these
|
|
77
79
|
// are said whether a page is open or not, because no page says them. The
|
|
@@ -314,6 +316,8 @@ export function normalise(raw) {
|
|
|
314
316
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
315
317
|
return {
|
|
316
318
|
notifications: flagOr(src.notifications, DEFAULTS.notifications),
|
|
319
|
+
recoverClaudeSessions: flagOr(src.recoverClaudeSessions, DEFAULTS.recoverClaudeSessions),
|
|
320
|
+
recoverCodexSessions: flagOr(src.recoverCodexSessions, DEFAULTS.recoverCodexSessions),
|
|
317
321
|
notifySwap: flagOr(src.notifySwap, DEFAULTS.notifySwap),
|
|
318
322
|
notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
|
|
319
323
|
notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
|
|
@@ -701,7 +705,7 @@ export function publicPrefs(prefs) {
|
|
|
701
705
|
* here — prefs-route-fields.test.ts lists them.
|
|
702
706
|
*/
|
|
703
707
|
export const PAGE_FIELDS = Object.freeze({
|
|
704
|
-
top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate"]),
|
|
708
|
+
top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
|
|
705
709
|
lan: Object.freeze([
|
|
706
710
|
"enabled", "name", "shared", "manual", "shareActive", "pairingMode",
|
|
707
711
|
"autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// imports reach pushEvent through event-sink.mjs, connected below as this
|
|
10
10
|
// module loads; index.mjs's routes import it from here. The bodies are
|
|
11
11
|
// unchanged.
|
|
12
|
+
import { noteToolAudit } from "./tool-audit.mjs";
|
|
12
13
|
import { PRODUCT } from "./brand.mjs";
|
|
13
14
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
14
15
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
@@ -30,6 +31,7 @@ import { notifyTrays, pageCount, sseClients, trayClients, writeSse } from "./sse
|
|
|
30
31
|
import { eventLogPath, maybeRotatePersistFile, writesLogFor } from "./event-log.mjs";
|
|
31
32
|
import { appendLogLine } from "./log-writer.mjs";
|
|
32
33
|
// The away-update's word on whether a turn is running — see lifecycle.mjs.
|
|
34
|
+
import { shouldTrackActivity } from "./activity.mjs";
|
|
33
35
|
import { activity } from "./lifecycle.mjs";
|
|
34
36
|
// The session LRU and the transcript watch — see session-tracking.mjs.
|
|
35
37
|
import { outputWatch, touchSession } from "./session-tracking.mjs";
|
|
@@ -122,6 +124,7 @@ export function pushEvent(raw, source, opts = {}) {
|
|
|
122
124
|
// Numbered, charged, held, and the ring evicted behind it — see admitEvent.
|
|
123
125
|
const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
|
|
124
126
|
const seq = evt.seq;
|
|
127
|
+
noteToolAudit(raw, source, opts);
|
|
125
128
|
|
|
126
129
|
// Does this event reach the log at all? Not on a replay (it came from
|
|
127
130
|
// there), not when the hook told us another deck owns this session's log,
|
|
@@ -202,9 +205,9 @@ export function pushEvent(raw, source, opts = {}) {
|
|
|
202
205
|
// know the other exists. block-notify.mjs holds the gates and the cooldown.
|
|
203
206
|
blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
|
|
204
207
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
if (
|
|
208
|
+
// Persisted history is not current activity. Fresh recovery snapshots are
|
|
209
|
+
// current evidence even though they bypass replayed-tool accounting.
|
|
210
|
+
if (shouldTrackActivity(raw, opts)) activity.note(raw, evt.receivedAt);
|
|
208
211
|
|
|
209
212
|
if (persisting) {
|
|
210
213
|
// Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
|
|
@@ -64,6 +64,8 @@ const nonEmpty = v => typeof v === "string" && v.trim() !== "";
|
|
|
64
64
|
const STAMPED = [
|
|
65
65
|
["model_context_window", v => typeof v === "number" && v > 0],
|
|
66
66
|
["approval_policy", v => typeof v === "string" && v !== ""],
|
|
67
|
+
// Null is a value here: a turn that names no effort clears the chip (#2155).
|
|
68
|
+
["reasoning_effort", v => (typeof v === "string" && v !== "") || v === null],
|
|
67
69
|
["context_tokens", v => typeof v === "number" && v >= 0],
|
|
68
70
|
];
|
|
69
71
|
|
|
@@ -139,6 +139,7 @@ export function upstreamForm({ fields, images }, facts) {
|
|
|
139
139
|
if (contact) text("contact", contact);
|
|
140
140
|
text("appVersion", facts.appVersion);
|
|
141
141
|
text("platform", facts.platform);
|
|
142
|
+
if (facts.deviceId) text("deviceId", facts.deviceId);
|
|
142
143
|
for (const [index, { data, format }] of images.entries()) {
|
|
143
144
|
const { type, extension } = FORMAT[format];
|
|
144
145
|
parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
|
package/src/server/index.mjs
CHANGED
|
@@ -83,6 +83,7 @@ import { ccProjectSlug } from "./claude-dir.mjs";
|
|
|
83
83
|
export { ccProjectSlug };
|
|
84
84
|
// The Codex half of capture: the rollouts directory tailed for its events —
|
|
85
85
|
// see codex-watch.mjs.
|
|
86
|
+
import { createClaudeRecovery } from "./claude-recovery.mjs";
|
|
86
87
|
import { startCodexWatcher } from "./codex-watch.mjs";
|
|
87
88
|
// Exported from this file before they moved, and still. scanCodexNow is new,
|
|
88
89
|
// and is here for the suites that import this file to drive the watcher.
|
|
@@ -164,6 +165,8 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
|
|
|
164
165
|
// The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
|
|
165
166
|
import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
|
|
166
167
|
// The usage panel's quota and history reads — see usage-routes.mjs.
|
|
168
|
+
import { startToolAudit } from "./tool-audit.mjs";
|
|
169
|
+
import { handleToolAudit } from "./tool-audit-routes.mjs";
|
|
167
170
|
import { startQuotaHistory } from "./quota-history.mjs";
|
|
168
171
|
import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
|
|
169
172
|
// The port fallback, one listen attempt, and the words for a failed one — see
|
|
@@ -192,6 +195,7 @@ export function requestUrl(rawUrl) {
|
|
|
192
195
|
|
|
193
196
|
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
|
|
194
197
|
startQuotaHistory();
|
|
198
|
+
await startToolAudit();
|
|
195
199
|
armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
|
|
196
200
|
// Which tree and which CLIs, normalised once — see setDeckScope.
|
|
197
201
|
setDeckScope({ workspace, claude, codex });
|
|
@@ -318,6 +322,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
318
322
|
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
319
323
|
if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
|
|
320
324
|
if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
|
|
325
|
+
if (["GET", "POST"].includes(req.method) && ["/api/tool-audit", "/api/tool-audit/storage"].includes(url.pathname)) return guard(handleToolAudit(req, res), res);
|
|
321
326
|
if (req.method === "GET" && url.pathname === "/api/quota/history") return guard(handleQuotaHistory(req, res), res);
|
|
322
327
|
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
323
328
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
@@ -510,6 +515,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
510
515
|
server.once("close", () => trafficCapture.dispose());
|
|
511
516
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
512
517
|
if (codex) startCodexWatcher(workspace);
|
|
518
|
+
if (claude) {
|
|
519
|
+
const recovery = createClaudeRecovery({ workspace });
|
|
520
|
+
const recover = () => { void recovery.scan().catch(() => {}); };
|
|
521
|
+
recover();
|
|
522
|
+
const timer = setInterval(recover, 1500);
|
|
523
|
+
timer.unref?.();
|
|
524
|
+
server.once("close", () => { clearInterval(timer); recovery.stop(); });
|
|
525
|
+
}
|
|
513
526
|
// What a session is producing between its tool calls. Claude only — it
|
|
514
527
|
// reads `transcript_path`, which Codex hooks never send — and unref'd
|
|
515
528
|
// like its neighbours.
|
|
@@ -53,6 +53,7 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
53
53
|
*/
|
|
54
54
|
const PINNED_MODULES = [
|
|
55
55
|
"self-update.mjs",
|
|
56
|
+
"tool-audit.mjs",
|
|
56
57
|
"claude-accounts.mjs",
|
|
57
58
|
"account-health.mjs",
|
|
58
59
|
"cswap-admin.mjs",
|
|
@@ -93,6 +94,10 @@ const PINNED_MODULES = [
|
|
|
93
94
|
// The plans the daily report says, read through import() by depth-facts.mjs.
|
|
94
95
|
"quota-oauth.mjs",
|
|
95
96
|
"codex-auth.mjs",
|
|
97
|
+
// The paired decks' current accounts, which Auto-switch's own rules skip
|
|
98
|
+
// (cswap-auto-choice.mjs). Already in memory through prefs-routes.mjs; named
|
|
99
|
+
// here because the tick reaches it through import().
|
|
100
|
+
"lan-deck.mjs",
|
|
96
101
|
];
|
|
97
102
|
|
|
98
103
|
let _pinned = null;
|
|
@@ -7,7 +7,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
|
|
|
7
7
|
import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
|
|
8
8
|
import { readBody, readBytes, send } from "./http-io.mjs";
|
|
9
9
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
10
|
-
import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
|
|
10
|
+
import { REPORTS_API, deviceIdToken, installFacts, reporter, reportsOn } from "./reports.mjs";
|
|
11
11
|
|
|
12
12
|
async function readJson(req, res, limit) {
|
|
13
13
|
const raw = await readBody(req, res, limit).catch(() => null);
|
|
@@ -72,35 +72,54 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
|
|
|
72
72
|
* one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
|
|
73
73
|
* that it "turns off everything but the quota reads", and a launch script's
|
|
74
74
|
* word about the machine outranks a button on the page.
|
|
75
|
+
*
|
|
76
|
+
* `device` is the fingerprint feedbackDeviceId picks; a test passes its own,
|
|
77
|
+
* or null for none.
|
|
75
78
|
*/
|
|
76
|
-
export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
|
|
79
|
+
export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env, device = feedbackDeviceId({ env }) } = {}) {
|
|
77
80
|
if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
|
|
81
|
+
// Left out of the post entirely when there is none: JSON drops an undefined.
|
|
82
|
+
const deviceId = device || undefined;
|
|
78
83
|
const contentType = req.headers?.["content-type"];
|
|
79
|
-
if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
|
|
84
|
+
if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId });
|
|
80
85
|
const body = await readJson(req, res, 32_000);
|
|
81
86
|
if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
82
87
|
const facts = feedbackFacts({ env });
|
|
83
88
|
const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
|
|
84
89
|
return forwardFeedback(res, fetchImpl, {
|
|
85
90
|
headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
|
|
86
|
-
body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
|
|
91
|
+
body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts, deviceId }),
|
|
87
92
|
timeoutMs: JSON_TIMEOUT_MS,
|
|
88
93
|
});
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
92
|
-
* What this deck adds to a piece of
|
|
93
|
-
* version, and the system as `os-arch` — `darwin-arm64`.
|
|
94
|
-
*
|
|
95
|
-
* and
|
|
96
|
-
*
|
|
97
|
-
*
|
|
97
|
+
* What the dialog says, before Send, that this deck adds to a piece of
|
|
98
|
+
* feedback: the ccdeck version, and the system as `os-arch` — `darwin-arm64`.
|
|
99
|
+
* Both posts carry these two, from this one function, so what the line shows
|
|
100
|
+
* and what the API gets for them cannot drift apart.
|
|
101
|
+
*
|
|
102
|
+
* The posts also carry the device fingerprint (feedbackDeviceId), and the line
|
|
103
|
+
* does not show it: the owner's decision of 2026-10-14, so a report can be
|
|
104
|
+
* tied to the install that sent it. installFacts' other fields (the channel,
|
|
105
|
+
* the runtime, a locale) never go with feedback.
|
|
98
106
|
*/
|
|
99
107
|
export function feedbackFacts({ env = process.env } = {}) {
|
|
100
108
|
const facts = installFacts({ env });
|
|
101
109
|
return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
|
|
102
110
|
}
|
|
103
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The device fingerprint a piece of feedback carries: the same one-way hash
|
|
114
|
+
* every usage report sends (deviceIdToken), and under the same rule — only
|
|
115
|
+
* while reports are on. Switched off, or vetoed with AGENTS_DECK_NO_REPORTS=1,
|
|
116
|
+
* the fingerprint stays home here as it does everywhere else, and the feedback
|
|
117
|
+
* still goes. It is read here, never taken from the page.
|
|
118
|
+
*/
|
|
119
|
+
export function feedbackDeviceId({ env = process.env, prefs = heldPrefs.current() } = {}) {
|
|
120
|
+
return reportsOn(prefs, env) ? deviceIdToken() : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/** `GET /api/feedback`: what a report sent from here would carry besides its
|
|
105
124
|
* words, for the dialog to say before Send. Nothing leaves the machine. */
|
|
106
125
|
export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
|
|
@@ -113,7 +132,7 @@ const JSON_TIMEOUT_MS = 20_000;
|
|
|
113
132
|
* seconds of upload before the API has read a byte of it. */
|
|
114
133
|
const FORM_TIMEOUT_MS = 120_000;
|
|
115
134
|
|
|
116
|
-
async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
|
|
135
|
+
async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId }) {
|
|
117
136
|
// Over the cap, readBytes has already answered 413 too_large and the 400
|
|
118
137
|
// below is a no-op; this is the answer to a read that failed some other way.
|
|
119
138
|
const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
|
|
@@ -125,7 +144,7 @@ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
|
|
|
125
144
|
return forwardFeedback(res, fetchImpl, {
|
|
126
145
|
// No content type: fetch writes the form's own, boundary and all.
|
|
127
146
|
headers: { "user-agent": `ccdeck/${facts.appVersion}` },
|
|
128
|
-
body: upstreamForm(form, facts),
|
|
147
|
+
body: upstreamForm(form, { ...facts, deviceId }),
|
|
129
148
|
timeoutMs: FORM_TIMEOUT_MS,
|
|
130
149
|
});
|
|
131
150
|
}
|