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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// (event-pipeline.mjs) hands every live event to maybeResolveCodex, and
|
|
11
11
|
// session-tracking.mjs forgets a session through forgetCodexSession. The
|
|
12
12
|
// bodies are unchanged.
|
|
13
|
+
import { codexCostState, codexCostGap, foldCodexCost, codexCostBuckets } from "./codex-cost-usage.mjs";
|
|
13
14
|
import { stat } from "node:fs/promises";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
|
|
@@ -48,6 +49,7 @@ import { sessionReadGate } from "./session-read-gate.mjs";
|
|
|
48
49
|
const codexRolloutPathBySid = new Map();
|
|
49
50
|
const CODEX_READ_THROTTLE_MS = 2500;
|
|
50
51
|
const codexUsageReads = sessionReadGate(CODEX_READ_THROTTLE_MS);
|
|
52
|
+
const codexUsageRequests = new Map();
|
|
51
53
|
// How many of the newest day directories a lookup reads when it is not owed
|
|
52
54
|
// the whole tree: the bound listRecentCodexRollouts keeps for the watcher.
|
|
53
55
|
const CODEX_RECENT_DAY_DIRS = 2;
|
|
@@ -211,6 +213,7 @@ export async function readCodexRollout(path) {
|
|
|
211
213
|
const s = await stat(path);
|
|
212
214
|
if (s.size === 0) return null;
|
|
213
215
|
let text;
|
|
216
|
+
const pricing = codexCostState();
|
|
214
217
|
if (s.size <= CODEX_HEAD_BYTES + CODEX_TAIL_BYTES) {
|
|
215
218
|
text = await readByteRange(path, 0, s.size);
|
|
216
219
|
} else {
|
|
@@ -221,7 +224,7 @@ export async function readCodexRollout(path) {
|
|
|
221
224
|
// the loop below skips it.
|
|
222
225
|
const head = await readByteRange(path, 0, CODEX_HEAD_BYTES);
|
|
223
226
|
const tail = await readByteRange(path, s.size - CODEX_TAIL_BYTES, s.size);
|
|
224
|
-
text = `${head}\n${tail}`;
|
|
227
|
+
text = `${head}\n__ccdeck_gap__\n${tail}`;
|
|
225
228
|
}
|
|
226
229
|
let lastUsage = null;
|
|
227
230
|
let model = null;
|
|
@@ -230,9 +233,11 @@ export async function readCodexRollout(path) {
|
|
|
230
233
|
// Undefined until a turn_context is read: "no turn yet" is not "no effort".
|
|
231
234
|
let effort;
|
|
232
235
|
for (const line of text.split("\n")) {
|
|
236
|
+
if (line === "__ccdeck_gap__") { codexCostGap(pricing); continue; }
|
|
233
237
|
if (!line) continue;
|
|
234
238
|
let obj;
|
|
235
239
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
240
|
+
foldCodexCost(pricing, obj);
|
|
236
241
|
// One record is one of these at most: each asks the record's own type.
|
|
237
242
|
const meta = sessionMeta(obj);
|
|
238
243
|
if (meta) {
|
|
@@ -251,28 +256,47 @@ export async function readCodexRollout(path) {
|
|
|
251
256
|
if (obj?.type === "turn_context") effort = turnContextEffort(obj.payload);
|
|
252
257
|
}
|
|
253
258
|
if (!lastUsage && !model && !contextWindow) return null;
|
|
254
|
-
|
|
259
|
+
const usageByModel = codexCostBuckets(pricing);
|
|
260
|
+
return { usage: lastUsage, model, contextWindow, cwd, effort,
|
|
261
|
+
...(usageByModel ? { usageByModel } : {}) };
|
|
255
262
|
} catch {
|
|
256
263
|
return null;
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
function maybeResolveCodex(payload) {
|
|
267
|
+
function maybeResolveCodex(payload, opts = {}) {
|
|
261
268
|
if (!payload || typeof payload !== "object") return;
|
|
262
269
|
if (payload.provider !== "codex") return;
|
|
263
270
|
const sid = payload.session_id;
|
|
264
271
|
if (!sid) return;
|
|
265
|
-
|
|
272
|
+
let entry = codexUsageRequests.get(sid);
|
|
273
|
+
if (!entry) {
|
|
274
|
+
entry = { revision: 0, opts: {}, timer: null };
|
|
275
|
+
codexUsageRequests.set(sid, entry);
|
|
276
|
+
}
|
|
277
|
+
entry.revision++;
|
|
278
|
+
entry.opts = { persist: opts.persist };
|
|
279
|
+
runCodexUsageRead(sid, entry);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function runCodexUsageRead(sid, entry) {
|
|
283
|
+
if (codexUsageRequests.get(sid) !== entry) return;
|
|
284
|
+
const revision = entry.revision;
|
|
285
|
+
const emitOpts = entry.opts;
|
|
286
|
+
const started = codexUsageReads.run(sid, async () => {
|
|
266
287
|
const path = await findCodexRolloutPath(sid);
|
|
267
|
-
if (!path) return;
|
|
288
|
+
if (!path || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
|
|
268
289
|
const r = await readCodexRollout(path);
|
|
269
|
-
|
|
290
|
+
// Watcher activity, eviction or shutdown can supersede either await.
|
|
291
|
+
// Never let a delayed snapshot overwrite newer live observations.
|
|
292
|
+
if (!r || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
|
|
270
293
|
if (r.usage) {
|
|
271
294
|
pushEvent({
|
|
272
295
|
hook_event_name: "UsageObserved",
|
|
273
296
|
session_id: sid,
|
|
274
297
|
usage: r.usage,
|
|
275
|
-
|
|
298
|
+
usageByModel: r.usageByModel ?? null,
|
|
299
|
+
}, "internal", emitOpts);
|
|
276
300
|
}
|
|
277
301
|
if (r.model) {
|
|
278
302
|
pushEvent({
|
|
@@ -282,7 +306,7 @@ function maybeResolveCodex(payload) {
|
|
|
282
306
|
// A hook-delivered session has no watcher to spread it, so it rides
|
|
283
307
|
// here, beside the model it qualifies.
|
|
284
308
|
...(r.effort !== undefined ? { reasoning_effort: r.effort } : {}),
|
|
285
|
-
}, "internal");
|
|
309
|
+
}, "internal", emitOpts);
|
|
286
310
|
}
|
|
287
311
|
if (r.contextWindow) {
|
|
288
312
|
// Piggy-back on the model event with the window — reducer reads
|
|
@@ -292,9 +316,21 @@ function maybeResolveCodex(payload) {
|
|
|
292
316
|
session_id: sid,
|
|
293
317
|
model: r.model ?? undefined,
|
|
294
318
|
model_context_window: r.contextWindow,
|
|
295
|
-
}, "internal");
|
|
319
|
+
}, "internal", emitOpts);
|
|
296
320
|
}
|
|
297
321
|
});
|
|
322
|
+
if (started) {
|
|
323
|
+
clearTimeout(entry.timer);
|
|
324
|
+
entry.timer = null;
|
|
325
|
+
} else if (!entry.timer) {
|
|
326
|
+
// Keep demand until a read actually starts, including while another read
|
|
327
|
+
// is pending. At most one unref'd retry timer exists for this session.
|
|
328
|
+
entry.timer = setTimeout(() => {
|
|
329
|
+
entry.timer = null;
|
|
330
|
+
runCodexUsageRead(sid, entry);
|
|
331
|
+
}, CODEX_READ_THROTTLE_MS + 1);
|
|
332
|
+
entry.timer.unref?.();
|
|
333
|
+
}
|
|
298
334
|
}
|
|
299
335
|
|
|
300
336
|
/**
|
|
@@ -305,6 +341,8 @@ function maybeResolveCodex(payload) {
|
|
|
305
341
|
function forgetCodexSession(sid) {
|
|
306
342
|
codexRolloutPathBySid.delete(sid);
|
|
307
343
|
codexUsageReads.forget(sid);
|
|
344
|
+
clearTimeout(codexUsageRequests.get(sid)?.timer);
|
|
345
|
+
codexUsageRequests.delete(sid);
|
|
308
346
|
codexSessionModel.delete(sid);
|
|
309
347
|
codexSessionApproval.delete(sid);
|
|
310
348
|
codexSessionEffort.delete(sid);
|
|
@@ -316,3 +354,9 @@ function forgetCodexSession(sid) {
|
|
|
316
354
|
// at each declaration, so the declarations read as they did where they came
|
|
317
355
|
// from.
|
|
318
356
|
export { findCodexRolloutPath, forgetCodexSession, maybeResolveCodex };
|
|
357
|
+
|
|
358
|
+
export function stopCodexEnrichment() {
|
|
359
|
+
for (const entry of codexUsageRequests.values()) clearTimeout(entry.timer);
|
|
360
|
+
codexUsageRequests.clear();
|
|
361
|
+
codexUsageReads.forgetAll();
|
|
362
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// OpenAI confirms ChatGPT auto-review checks are free:
|
|
2
|
+
// https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan
|
|
3
|
+
// The announcement was on Oct 6. Use the following UTC day conservatively;
|
|
4
|
+
// do not infer the exact historical billing cutover from the current policy.
|
|
5
|
+
export const AUTO_REVIEW_MODEL = "codex-auto-review";
|
|
6
|
+
export const FREE_AUTO_REVIEW_TIER = "chatgpt-auto-review-free";
|
|
7
|
+
export const FREE_AUTO_REVIEW_FROM = Date.parse("2026-10-07T00:00:00Z");
|
|
8
|
+
export function isFreeAutoReview(model, creator, at) {
|
|
9
|
+
return model === AUTO_REVIEW_MODEL && typeof creator === "string" && creator.trim().length > 0
|
|
10
|
+
&& typeof at === "string" && Date.parse(at) >= FREE_AUTO_REVIEW_FROM;
|
|
11
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Which Codex skill a rollout line shows in use, for Skill activity (#2181).
|
|
2
|
+
//
|
|
3
|
+
// Codex writes no record of its own that a skill ran. Two things in a rollout
|
|
4
|
+
// say so anyway:
|
|
5
|
+
//
|
|
6
|
+
// - the `<skill>` message Codex injects when a person picks one with `$name`
|
|
7
|
+
// or /skills, which it writes only once the skill has loaded (explicit);
|
|
8
|
+
// - the model reading a skill's SKILL.md, or running one of its scripts, which
|
|
9
|
+
// is Codex's own rule for a skill it chose itself (detected).
|
|
10
|
+
//
|
|
11
|
+
// A detected use counts only for a skill in the catalog Codex gave that session
|
|
12
|
+
// (its `<skills_instructions>` message) and only when the command succeeded, so
|
|
13
|
+
// searching for SKILL.md files or reading a Claude skill is not a use. One use
|
|
14
|
+
// per turn and skill: the turn id is the row's key, which also lands a forked
|
|
15
|
+
// rollout's copy of its parent's turns on the rows the parent already wrote.
|
|
16
|
+
import { posix, win32 } from "node:path";
|
|
17
|
+
import { noteSkillUse } from "./tool-audit.mjs";
|
|
18
|
+
import { readByteRange } from "./jsonl-chunks.mjs";
|
|
19
|
+
|
|
20
|
+
// The catalog sits in the first lines of a rollout, a few tens of KB in.
|
|
21
|
+
const CATALOG_HEAD_BYTES = 512 * 1024;
|
|
22
|
+
const slashes = path => path.replace(/\\/g, "/");
|
|
23
|
+
|
|
24
|
+
/** The skills a `<skills_instructions>` message lists: SKILL.md path → name,
|
|
25
|
+
* and each skill's folder for its scripts. Null when the text has none. */
|
|
26
|
+
export function parseSkillCatalog(text) {
|
|
27
|
+
const start = typeof text === "string" ? text.indexOf("<skills_instructions>") : -1;
|
|
28
|
+
if (start < 0) return null;
|
|
29
|
+
const roots = new Map(), files = new Map(), dirs = [];
|
|
30
|
+
const end = text.indexOf("</skills_instructions>", start);
|
|
31
|
+
for (const line of text.slice(start, end < 0 ? undefined : end).split("\n")) {
|
|
32
|
+
const root = /^- `(r\d+)` = `(.+)`\s*$/.exec(line);
|
|
33
|
+
if (root) { roots.set(root[1], slashes(root[2]).replace(/\/+$/, "")); continue; }
|
|
34
|
+
const entry = /^- (\S+): .*\(file: (r\d+)\/([^()]*\/SKILL\.md)\)\s*$/.exec(line);
|
|
35
|
+
if (!entry || !roots.has(entry[2])) continue;
|
|
36
|
+
const file = `${roots.get(entry[2])}/${slashes(entry[3])}`;
|
|
37
|
+
files.set(file, entry[1]);
|
|
38
|
+
dirs.push([file.slice(0, -"SKILL.md".length), entry[1]]);
|
|
39
|
+
}
|
|
40
|
+
return files.size ? { files, dirs } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The catalog a rollout began with, for a session this deck joined late. */
|
|
44
|
+
export async function readCodexSkillCatalog(path) {
|
|
45
|
+
try {
|
|
46
|
+
const head = await readByteRange(path, 0, CATALOG_HEAD_BYTES);
|
|
47
|
+
for (const line of head.split("\n")) {
|
|
48
|
+
if (!line.includes("<skills_instructions>")) continue;
|
|
49
|
+
return parseSkillCatalog(messageText(JSON.parse(line)?.payload));
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function messageText(payload) {
|
|
56
|
+
if (payload?.type !== "message" || !Array.isArray(payload.content)) return "";
|
|
57
|
+
return payload.content.map(part => typeof part?.text === "string" ? part.text : "").join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A rollout's paths are its own machine's, so their shape picks the rules, not
|
|
61
|
+
// the host reading them: `fileURLToPath` refuses a drive-less file URL on
|
|
62
|
+
// Windows, and the host's `resolve` would put its own drive letter in front.
|
|
63
|
+
const windowsPath = path => /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/.test(path);
|
|
64
|
+
const localPath = value => {
|
|
65
|
+
if (typeof value !== "string" || !value) return null;
|
|
66
|
+
if (!value.startsWith("file://")) return value;
|
|
67
|
+
try {
|
|
68
|
+
const { host, pathname } = new URL(value);
|
|
69
|
+
const path = decodeURIComponent(pathname);
|
|
70
|
+
return host ? `//${host}${path}` : /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
};
|
|
73
|
+
const resolveIn = (cwd, path) => (windowsPath(cwd) ? win32 : posix).resolve(cwd, path);
|
|
74
|
+
|
|
75
|
+
/** A finished command that may have touched a skill: worth reading the catalog for. */
|
|
76
|
+
const commandItem = obj => {
|
|
77
|
+
const item = obj?.type === "event_msg" && obj.payload?.type === "item_completed" ? obj.payload.item : null;
|
|
78
|
+
return item?.type === "CommandExecution" && item.exit_code === 0 && Array.isArray(item.parsed_cmd) ? item : null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Per-rollout state: the catalog (undefined until known, null for none), the
|
|
82
|
+
* turn the last `task_started` opened, and the turn-and-skill keys counted. */
|
|
83
|
+
export const codexSkillState = () => ({ catalog: undefined, turn: null, seen: new Set(), waiting: [], loading: false });
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The skill uses one rollout line shows, as `{ key, name, origin, at }`.
|
|
87
|
+
* `skills` is the rollout's codexSkillState. A command names its own turn; the
|
|
88
|
+
* injected `<skill>` message names none and belongs to the turn just opened.
|
|
89
|
+
*/
|
|
90
|
+
export function codexSkillUses(obj, skills) {
|
|
91
|
+
const pl = obj?.payload;
|
|
92
|
+
const at = typeof obj?.timestamp === "string" ? Date.parse(obj.timestamp) : undefined;
|
|
93
|
+
if (obj?.type === "event_msg" && pl?.type === "task_started" && typeof pl.turn_id === "string") { skills.turn = pl.turn_id; return []; }
|
|
94
|
+
const turn = (typeof pl?.turn_id === "string" ? pl.turn_id : skills.turn) ?? `at:${obj?.timestamp ?? ""}`;
|
|
95
|
+
const use = (name, origin) => {
|
|
96
|
+
const key = `${turn}|${name}`;
|
|
97
|
+
if (skills.seen.has(key)) return [];
|
|
98
|
+
if (skills.seen.size >= 256) skills.seen.clear();
|
|
99
|
+
skills.seen.add(key);
|
|
100
|
+
return [{ key, name, origin, at }];
|
|
101
|
+
};
|
|
102
|
+
if (obj?.type === "response_item" && pl?.type === "message") {
|
|
103
|
+
const text = messageText(pl);
|
|
104
|
+
if (pl.role === "developer" && text.includes("<skills_instructions>")) { skills.catalog = parseSkillCatalog(text); return []; }
|
|
105
|
+
if (pl.role !== "user") return [];
|
|
106
|
+
return [...text.matchAll(/<skill>\s*<name>([^<]+)<\/name>/g)].flatMap(m => use(m[1].trim(), "typed"));
|
|
107
|
+
}
|
|
108
|
+
const item = commandItem(obj);
|
|
109
|
+
if (!item || !skills.catalog) return [];
|
|
110
|
+
const cwd = localPath(item.cwd);
|
|
111
|
+
const found = [];
|
|
112
|
+
for (const parsed of item.parsed_cmd) {
|
|
113
|
+
if (parsed?.type === "read" && typeof parsed.path === "string") {
|
|
114
|
+
const name = skills.catalog.files.get(slashes(cwd ? resolveIn(cwd, parsed.path) : parsed.path));
|
|
115
|
+
if (name) found.push(name);
|
|
116
|
+
} else if (parsed?.type === "unknown" && typeof parsed.cmd === "string") {
|
|
117
|
+
const cmd = slashes(parsed.cmd);
|
|
118
|
+
for (const [dir, name] of skills.catalog.dirs) if (cmd.includes(`${dir}scripts/`)) found.push(name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...new Set(found)].flatMap(name => use(name, "detected"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Count the skills one rollout line shows. `state` is the watcher's cursor for
|
|
126
|
+
* the rollout: its session, cwd, subagent link and path. A command that may
|
|
127
|
+
* read a skill, in a session joined after its catalog went by, waits for the
|
|
128
|
+
* catalog to be read off the head of the file.
|
|
129
|
+
*/
|
|
130
|
+
export function noteCodexSkills(obj, state, note = noteSkillUse) {
|
|
131
|
+
const skills = state.skills ??= codexSkillState();
|
|
132
|
+
const session = state.link?.parent_session_id ?? state.sid, cwd = state.cwd, subagent = !!state.link;
|
|
133
|
+
const send = uses => { for (const use of uses) note({ ...use, session, cwd, subagent }); };
|
|
134
|
+
if (skills.catalog === undefined && commandItem(obj) && state.path) {
|
|
135
|
+
skills.waiting.push(obj);
|
|
136
|
+
if (!skills.loading) {
|
|
137
|
+
skills.loading = true;
|
|
138
|
+
void readCodexSkillCatalog(state.path).then(catalog => {
|
|
139
|
+
if (skills.catalog === undefined) skills.catalog = catalog;
|
|
140
|
+
for (const line of skills.waiting.splice(0)) send(codexSkillUses(line, skills));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
send(codexSkillUses(obj, skills));
|
|
146
|
+
}
|
|
@@ -16,6 +16,7 @@ import { STOP, rolloutNameId, sidFromRolloutName, walkRolloutDays } from "./code
|
|
|
16
16
|
// record that says whose rollout it is — see codex-translate.mjs. The watcher
|
|
17
17
|
// below still decides which lines are read and where each one goes.
|
|
18
18
|
import { codexObjToPayload, codexSessionModel, sessionMeta } from "./codex-translate.mjs";
|
|
19
|
+
import { noteCodexSkills } from "./codex-skills.mjs";
|
|
19
20
|
import { codexCwdInWorkspace, writesCodexLog } from "./log-election.mjs";
|
|
20
21
|
// The one spelling of a rollout's cwd — see canonical-path.mjs.
|
|
21
22
|
import { canonicalCwd } from "./canonical-path.mjs";
|
|
@@ -351,7 +352,7 @@ async function openCodexCursor(path, st, now, firstRun, recover = false) {
|
|
|
351
352
|
// UsageObserved in the file to every tab, and re-appending the lot to
|
|
352
353
|
// the shared events.jsonl if this deck is the elected writer (#981).
|
|
353
354
|
const fresh = !codexSeenEver.has(path);
|
|
354
|
-
const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
|
|
355
|
+
const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now, path };
|
|
355
356
|
codexFileState.set(path, state);
|
|
356
357
|
rememberCodexPath(path);
|
|
357
358
|
if (firstRun) {
|
|
@@ -409,6 +410,8 @@ function emitCodexLines(state, consume, persist) {
|
|
|
409
410
|
if (!line) continue;
|
|
410
411
|
let obj;
|
|
411
412
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
413
|
+
// Skill activity reads the line itself: what says a skill ran is not an event.
|
|
414
|
+
noteCodexSkills(obj, state);
|
|
412
415
|
const prevModel = codexSessionModel.get(state.sid);
|
|
413
416
|
const payload = codexObjToPayload(obj, state.sid, state.cwd);
|
|
414
417
|
const link = codexThreadLink(obj, state.sid, state.cwd);
|
|
@@ -486,7 +489,7 @@ async function openGrownOlderRollouts(now) {
|
|
|
486
489
|
// Never `sawBeginning`, whatever this process read of the file before: the
|
|
487
490
|
// lines before `was` are ones this deck has either drawn already or joined
|
|
488
491
|
// too late for, and either way a SessionStart now would be a false one.
|
|
489
|
-
const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, link: header.link, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs };
|
|
492
|
+
const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, link: header.link, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs, path };
|
|
490
493
|
codexFileState.set(path, state);
|
|
491
494
|
rememberCodexPath(path);
|
|
492
495
|
opened.push({ path, st, state });
|
|
@@ -181,6 +181,23 @@ async function accountsInCswapRun(accounts) {
|
|
|
181
181
|
return live;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The slots no move may land on, whatever the rule: the ones claude-swap's
|
|
186
|
+
* engine quarantined and the ones a local `cswap run` session is using — the
|
|
187
|
+
* two `landable` skips by key. For the failover away from an expired login
|
|
188
|
+
* (autoswitch-failover.mjs), which moves without asking the rules, so it
|
|
189
|
+
* never lands on another dead login or on a login a session already holds.
|
|
190
|
+
* Roster rows in, slot numbers out.
|
|
191
|
+
*/
|
|
192
|
+
export async function slotsHeldBack(roster) {
|
|
193
|
+
const accounts = (Array.isArray(roster) ? roster : [])
|
|
194
|
+
.filter(a => a && a.num != null)
|
|
195
|
+
.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
|
|
196
|
+
const engine = await engineState(accounts);
|
|
197
|
+
const busy = await accountsInCswapRun(accounts);
|
|
198
|
+
return new Set(accounts.filter(a => engine.quarantined.has(a.key) || busy.has(a.key)).map(a => a.num));
|
|
199
|
+
}
|
|
200
|
+
|
|
184
201
|
const numberOr = (v, fallback) => {
|
|
185
202
|
const n = Number(v);
|
|
186
203
|
return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
|
|
@@ -294,3 +311,39 @@ export async function noteEngineTick(result, accounts) {
|
|
|
294
311
|
const to = accounts.find(a => String(a.num) === String(result.to?.number));
|
|
295
312
|
await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
|
|
296
313
|
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The two rules as `ccdeck switch --best` asks them (#2183): what
|
|
317
|
+
* account-pick.mjs bestAccount hands chooseAccount, read the way
|
|
318
|
+
* chooseBeforeEngine reads it — the threshold, the counted models and the
|
|
319
|
+
* engine's strategy from claude-swap's config, the accounts its engine
|
|
320
|
+
* quarantined and the ones a local `cswap run` session holds. Null when
|
|
321
|
+
* neither rule is set, before anything else is read: `--best` is then the
|
|
322
|
+
* engine's own choice, as every tick is.
|
|
323
|
+
*
|
|
324
|
+
* `choice` and `settings` are the deck's (GET /api/cswap-auto) when a deck
|
|
325
|
+
* answered, so the rules are the ones its panel shows; with no deck they are
|
|
326
|
+
* read here. Only reads: nothing in auto-choice.json or claude-swap's store
|
|
327
|
+
* changes, so it needs no lock and takes none.
|
|
328
|
+
*/
|
|
329
|
+
export async function bestRules(rows, { choice = null, settings = null, now = Date.now() } = {}) {
|
|
330
|
+
const c = choice ?? await choiceStatus();
|
|
331
|
+
const next = c?.next === "weekly" ? "weekly" : "room";
|
|
332
|
+
const home = text(c?.home?.email) ? accountKey(c.home.email, c.home.orgUuid) : null;
|
|
333
|
+
if (next === "room" && !home) return null;
|
|
334
|
+
const cfg = (settings ?? await readCswapConfig()) ?? {};
|
|
335
|
+
const accounts = (Array.isArray(rows) ? rows : [])
|
|
336
|
+
.filter(a => a && a.num != null)
|
|
337
|
+
.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
|
|
338
|
+
const engine = await engineState(accounts);
|
|
339
|
+
return {
|
|
340
|
+
next,
|
|
341
|
+
home,
|
|
342
|
+
threshold: numberOr(cfg["autoswitch.threshold"]?.value, 90),
|
|
343
|
+
models: countedModels(cfg["autoswitch.model"]?.value),
|
|
344
|
+
busy: await accountsInCswapRun(accounts),
|
|
345
|
+
quarantined: engine.quarantined,
|
|
346
|
+
optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
|
|
347
|
+
now,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -18,11 +18,18 @@ import { invalidateQuotaCache } from "./quota.mjs";
|
|
|
18
18
|
import { withStoreLock } from "./store-lock.mjs";
|
|
19
19
|
// Told about every finished tick and about the switch itself, so a tick that
|
|
20
20
|
// moved the account is said once on the desktop (account-notify.mjs). Both
|
|
21
|
-
// doors return at once and never throw.
|
|
22
|
-
|
|
21
|
+
// doors return at once and never throw. The third is the other way round: how a
|
|
22
|
+
// session that stopped on a dead login asks for a tick now (tickSoon).
|
|
23
|
+
import { connectEarlyTick, noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
|
|
23
24
|
// The owner's two rules — a home account, and the soonest weekly reset — asked
|
|
24
25
|
// before the engine, inside the same lock. With neither set, nothing.
|
|
25
|
-
import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick } from "./cswap-auto-choice.mjs";
|
|
26
|
+
import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick, slotsHeldBack } from "./cswap-auto-choice.mjs";
|
|
27
|
+
// The one decision the engine cannot reach under `--once`: leaving an active
|
|
28
|
+
// account whose login has died. See its header.
|
|
29
|
+
import { engineLostActive, failOver } from "./autoswitch-failover.mjs";
|
|
30
|
+
// And the automatic continue of stopped background sessions (#2193), which a
|
|
31
|
+
// tick that moved the account is the likeliest moment for. Returns at once.
|
|
32
|
+
import { noteContinueTick } from "./stop-continue.mjs";
|
|
26
33
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
27
34
|
import { join } from "node:path";
|
|
28
35
|
import { homedir } from "node:os";
|
|
@@ -55,7 +62,8 @@ function summarise(stdout) {
|
|
|
55
62
|
// here throws away readings that cost a subprocess each.
|
|
56
63
|
switched: events.some(e => e.event === "switch" && e.dryRun !== true),
|
|
57
64
|
// Why the engine moved — "proactive", "at-limit", "failover" — beside the
|
|
58
|
-
// deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs)
|
|
65
|
+
// deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs), and
|
|
66
|
+
// "login-expired", which the deck's failover writes here.
|
|
59
67
|
trigger: action?.trigger ?? null,
|
|
60
68
|
reason: action?.reason ?? null,
|
|
61
69
|
detail: action?.detail ?? null,
|
|
@@ -121,9 +129,13 @@ async function runAutoTick() {
|
|
|
121
129
|
// A deck-managed `cswap switch` does not advance the engine's own
|
|
122
130
|
// lastSwitchAt. Its fallback must keep our cooldown too, or a manual
|
|
123
131
|
// return to the full account can immediately escape the weekly rule
|
|
124
|
-
// through the engine's most-room choice. Still allow emergency exits
|
|
132
|
+
// through the engine's most-room choice. Still allow emergency exits:
|
|
133
|
+
// the engine's own at-limit and failover, and an active account it could
|
|
134
|
+
// not read, which may be a login that has died — the deck's failover
|
|
135
|
+
// judges that one after the tick (autoswitch-failover.mjs).
|
|
125
136
|
const decision = summarise(preview.stdout);
|
|
126
|
-
if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover"
|
|
137
|
+
if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover"
|
|
138
|
+
&& !engineLostActive({ ok: true, ...decision })) {
|
|
127
139
|
return { chose: {
|
|
128
140
|
ok: true, ...decision, event: "no-switch", switched: false,
|
|
129
141
|
trigger: null, reason: "cooldown", from: null, to: null,
|
|
@@ -134,6 +146,7 @@ async function runAutoTick() {
|
|
|
134
146
|
const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
|
|
135
147
|
return { ...out, accounts: chose.accounts };
|
|
136
148
|
});
|
|
149
|
+
_ruleAccounts = r.accounts ?? null;
|
|
137
150
|
if (r.skipped) return { event: "skipped", reason: r.skipped };
|
|
138
151
|
if (r.chose) return r.chose;
|
|
139
152
|
// A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
|
|
@@ -159,6 +172,11 @@ async function runAutoTick() {
|
|
|
159
172
|
let _timer = null;
|
|
160
173
|
let _lastTick = null;
|
|
161
174
|
let _enabled = false;
|
|
175
|
+
// When the deck last switched away from a dead login, for failOver's cooldown.
|
|
176
|
+
let _lastFailoverAt = null;
|
|
177
|
+
// The roster the home/weekly rules decided on in this tick, or null when none
|
|
178
|
+
// is set: the failover's move is a tick's, and noteEngineTick records it so.
|
|
179
|
+
let _ruleAccounts = null;
|
|
162
180
|
// Set the instant startLoop is entered and cleared when it settles, because
|
|
163
181
|
// `_timer` cannot do that job: it is assigned AFTER an await, and the window in
|
|
164
182
|
// between is what #537 was. See startLoop.
|
|
@@ -169,6 +187,9 @@ let _starting = false;
|
|
|
169
187
|
let _restartWanted = false;
|
|
170
188
|
// The tick in flight, so the interval can skip rather than stack. See tick.
|
|
171
189
|
let _ticking = null;
|
|
190
|
+
// When the tick in flight began, so an early tick can tell whether the one
|
|
191
|
+
// running already started after it was asked for. See tickSoon.
|
|
192
|
+
let _tickStartedAt = 0;
|
|
172
193
|
// What every tick waits for before it runs anything: the launcher's word that
|
|
173
194
|
// the claude-swap it drives is not being installed or upgraded underneath it.
|
|
174
195
|
// Settled by default, so a caller with nothing to wait for — a respawn, a test,
|
|
@@ -244,7 +265,25 @@ async function runTick() {
|
|
|
244
265
|
_lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
|
|
245
266
|
return;
|
|
246
267
|
}
|
|
247
|
-
|
|
268
|
+
let result = await runAutoTick();
|
|
269
|
+
// A tick that could not read the active account may be a login that has
|
|
270
|
+
// died, which the engine cannot leave under --once (autoswitch-failover.mjs).
|
|
271
|
+
// The switch asks both of this function's questions again inside the lock,
|
|
272
|
+
// for the same reason runAutoTick does (#1797).
|
|
273
|
+
const failover = await failOver(result, {
|
|
274
|
+
lastAt: _lastFailoverAt,
|
|
275
|
+
before: async () => (!_enabled ? "disabled" : (await externalAutoRunning()) ? "external-engine" : null),
|
|
276
|
+
heldBack: slotsHeldBack,
|
|
277
|
+
}).catch(() => null);
|
|
278
|
+
if (failover) {
|
|
279
|
+
result = { ...result, ...failover };
|
|
280
|
+
if (failover.switched) {
|
|
281
|
+
_lastFailoverAt = Date.now();
|
|
282
|
+
// Where the failover left the user is a tick's pick, not one made by
|
|
283
|
+
// hand, so the home/weekly rules and their cooldown go on from it.
|
|
284
|
+
await noteEngineTick(result, _ruleAccounts).catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
248
287
|
// Before `_lastTick`, not after. This is the only path in the deck that moves
|
|
249
288
|
// the live account without a click behind it, so nothing else is in a position
|
|
250
289
|
// to make the call — and `_lastTick` is what /api/cswap-auto reports, so
|
|
@@ -286,12 +325,63 @@ async function runTick() {
|
|
|
286
325
|
function tick() {
|
|
287
326
|
if (_ticking) return _ticking;
|
|
288
327
|
// The notifier hears what every tick did, the ones that switched nothing
|
|
289
|
-
// included: a threshold notice it is holding waits for exactly that answer
|
|
328
|
+
// included: a threshold notice it is holding waits for exactly that answer,
|
|
329
|
+
// and a stopped notice for one that began after the stop.
|
|
290
330
|
// Not awaited, and caught inside: a notification never delays a tick.
|
|
291
|
-
|
|
331
|
+
const startedAt = Date.now();
|
|
332
|
+
_tickStartedAt = startedAt;
|
|
333
|
+
_ticking = runTick().then(() => { noteAutoTick(_lastTick, { startedAt }); noteContinueTick(_lastTick); }).finally(() => { _ticking = null; });
|
|
292
334
|
return _ticking;
|
|
293
335
|
}
|
|
294
336
|
|
|
337
|
+
/** How long an early tick waits for the rest of a burst: sessions sharing a
|
|
338
|
+
* login stop within a second or two of each other. */
|
|
339
|
+
export const SOON_MS = 1_500;
|
|
340
|
+
|
|
341
|
+
/** The least time between two early ticks. The stops arrive on `/api/event`,
|
|
342
|
+
* which takes a post without the deck's token, so this is what keeps a loop
|
|
343
|
+
* of fake ones from running `cswap auto --once` back to back — the interval's
|
|
344
|
+
* own floor is fifteen seconds, and this is twice that. */
|
|
345
|
+
export const SOON_GAP_MS = 30_000;
|
|
346
|
+
|
|
347
|
+
let _soon = null;
|
|
348
|
+
let _soonAskedAt = -Infinity;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Run one tick now rather than at the next interval, because a session has
|
|
352
|
+
* just stopped on an expired login or a usage limit (account-watch.mjs
|
|
353
|
+
* `noteHookStop`). The same tick the interval runs, through the same guards,
|
|
354
|
+
* so nothing about what the engine decides changes — only when it is asked.
|
|
355
|
+
*
|
|
356
|
+
* COALESCED: the first ask arms one tick SOON_MS later and every ask until it
|
|
357
|
+
* runs joins it, so ten sessions stopping in the same second cause one tick;
|
|
358
|
+
* after it, asks inside SOON_GAP_MS are dropped, and the interval carries on.
|
|
359
|
+
* NEVER BESIDE A RUNNING TICK: one in flight is waited for, and is enough by
|
|
360
|
+
* itself when it began after the ask. Off, this does nothing.
|
|
361
|
+
*
|
|
362
|
+
* Returns whether a tick was armed, for the suite.
|
|
363
|
+
*/
|
|
364
|
+
export function tickSoon() {
|
|
365
|
+
if (!_enabled || _soon) return false;
|
|
366
|
+
const askedAt = Date.now();
|
|
367
|
+
if (askedAt - _soonAskedAt < SOON_GAP_MS) return false;
|
|
368
|
+
_soonAskedAt = askedAt;
|
|
369
|
+
_soon = setTimeout(async () => {
|
|
370
|
+
_soon = null;
|
|
371
|
+
try {
|
|
372
|
+
if (_ticking) {
|
|
373
|
+
const covered = _tickStartedAt >= askedAt;
|
|
374
|
+
await _ticking;
|
|
375
|
+
if (covered) return;
|
|
376
|
+
}
|
|
377
|
+
if (_enabled) await tick();
|
|
378
|
+
} catch { /* a tick reports its own failures in _lastTick */ }
|
|
379
|
+
}, SOON_MS);
|
|
380
|
+
_soon.unref?.();
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
connectEarlyTick(tickSoon);
|
|
384
|
+
|
|
295
385
|
/**
|
|
296
386
|
* Start the deck-managed loop, at most once.
|
|
297
387
|
*
|
|
@@ -354,6 +444,7 @@ async function startLoop() {
|
|
|
354
444
|
|
|
355
445
|
function stopLoop() {
|
|
356
446
|
if (_timer) { clearInterval(_timer); _timer = null; }
|
|
447
|
+
if (_soon) { clearTimeout(_soon); _soon = null; }
|
|
357
448
|
}
|
|
358
449
|
|
|
359
450
|
/** Put a new interval into effect: the timer goes, and a fresh read of the
|