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 @@
|
|
|
1
|
+
import"./app-9p127oSR.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
package/dist/web/index.html
CHANGED
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
<script>
|
|
54
54
|
(function () {
|
|
55
55
|
// Generated from JSON alongside the CSS and Settings catalog.
|
|
56
|
-
var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha"] /* theme-ids:end */;
|
|
57
|
-
var lightThemes = /* theme-light-ids:start */ ["light","white-contrast"] /* theme-light-ids:end */;
|
|
56
|
+
var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha","gruvbox-dark","gruvbox-light","gruvbox-material"] /* theme-ids:end */;
|
|
57
|
+
var lightThemes = /* theme-light-ids:start */ ["light","white-contrast","gruvbox-light"] /* theme-light-ids:end */;
|
|
58
58
|
var stored = null;
|
|
59
59
|
// `window.localStorage` is a GETTER that throws where site data is
|
|
60
60
|
// blocked, so the property read is inside the try, not just getItem.
|
|
@@ -66,10 +66,24 @@
|
|
|
66
66
|
document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
|
|
67
67
|
})();
|
|
68
68
|
</script>
|
|
69
|
-
<script
|
|
70
|
-
|
|
69
|
+
<script>
|
|
70
|
+
(function () {
|
|
71
|
+
var size = null, density = null;
|
|
72
|
+
try {
|
|
73
|
+
size = window.localStorage.getItem("agent-dag.text-size");
|
|
74
|
+
density = window.localStorage.getItem("agent-dag.density");
|
|
75
|
+
} catch (e) {}
|
|
76
|
+
document.documentElement.setAttribute("data-text-size", size === "smallest" || size === "smaller" || size === "larger" || size === "largest" ? size : "default");
|
|
77
|
+
document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
|
|
78
|
+
})();
|
|
79
|
+
</script>
|
|
80
|
+
<script type="module" crossorigin src="/assets/index-Bkx27-wy.js"></script>
|
|
81
|
+
<link rel="modulepreload" crossorigin href="/assets/app-9p127oSR.js">
|
|
82
|
+
<link rel="stylesheet" crossorigin href="/assets/app-DsHSufBt.css">
|
|
71
83
|
</head>
|
|
72
84
|
<body>
|
|
73
85
|
<div id="root"></div>
|
|
86
|
+
<!-- entry.ts, not main.tsx: the entry holds no app code, so the app runs
|
|
87
|
+
once whatever URL the entry is loaded under. See entry.ts (#2162). -->
|
|
74
88
|
</body>
|
|
75
89
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.43.0",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -78,6 +78,46 @@
|
|
|
78
78
|
"3.40.1",
|
|
79
79
|
"3.41.3"
|
|
80
80
|
],
|
|
81
|
+
"3.43.0": [
|
|
82
|
+
{
|
|
83
|
+
"title": "🏠 Auto-switch can come back to a home account",
|
|
84
|
+
"body": "Choose Set as home in an account’s ⋯ menu. With Auto-switch on, Next account › Soonest weekly reset uses up quota that resets within 4 days first."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"title": "🔄 Sessions already running appear when ccdeck starts",
|
|
88
|
+
"body": "Busy Codex sessions, and Claude ones on macOS and Linux, show up again after a restart. Restore unfinished sessions, in Settings › General, turns it off."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"title": "⚙️ Settings, reorganized and searchable",
|
|
92
|
+
"body": "Search every setting from the top of the list. Keyboard, Data and About have sections of their own."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"title": "📋 Plan progress and reasoning effort on agent cards",
|
|
96
|
+
"body": "Cards show how far an agent is through its plan, and the effort beside the model. Finished sessions can hide themselves after a delay you choose."
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"title": "✨ Calmer desktop updates, marked shared accounts, Codex sub-agents kept together",
|
|
100
|
+
"body": "The desktop app installs an update only when no agent is working or waiting. Accounts shared on Local network are marked."
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
"3.42.0": [
|
|
104
|
+
{
|
|
105
|
+
"title": "🔠 Text size and density, in Settings",
|
|
106
|
+
"body": "Five text sizes and five spacing levels, under Settings › Text & density."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"title": "🛠️ Tool activity: which tools your agents use, how long they take and what fails",
|
|
110
|
+
"body": "A new page on the stripe on the right. Its history is kept on this device only."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"title": "🎨 Gruvbox Dark, Light and Material themes",
|
|
114
|
+
"body": "Pick one in Settings › General. Switching themes now fades instead of snapping."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"title": "✨ A session-list drawer on phones, whole dates in usage history, the waiting session in the minimap",
|
|
118
|
+
"body": "Smaller fixes for the phone layout, the usage history chart and the canvas minimap."
|
|
119
|
+
}
|
|
120
|
+
],
|
|
81
121
|
"3.41.1": [
|
|
82
122
|
{
|
|
83
123
|
"title": "🪟 More reliable Windows updates",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Which account Auto-switch should be on, by the owner's two rules — a home
|
|
2
|
+
// account it comes back to, and "soonest weekly reset" as the way it picks the
|
|
3
|
+
// next one — asked before claude-swap's own engine runs.
|
|
4
|
+
//
|
|
5
|
+
// THE ENGINE STAYS THE SAFETY NET. claude-swap decides with `best` exactly as it
|
|
6
|
+
// did before; this only answers when one of the two rules has something better
|
|
7
|
+
// to say, and answers nothing otherwise. Nothing here is consulted at all while
|
|
8
|
+
// neither rule is set, so a deck that never touched them switches as it always
|
|
9
|
+
// did.
|
|
10
|
+
//
|
|
11
|
+
// WHY NOT claude-swap's OWN `consume-first`. It ranks by the weekly reset too,
|
|
12
|
+
// but it has no horizon, skips the hysteresis gate below the threshold, and
|
|
13
|
+
// pulls away from any account it is on — and upstream measured endless
|
|
14
|
+
// 1→2→1→3 cycles when it was combined with a preferred account (PR #260). So
|
|
15
|
+
// the engine is left on `best`, and while it is not, the moves below the
|
|
16
|
+
// threshold are not made here either: two strategies would take turns.
|
|
17
|
+
//
|
|
18
|
+
// WHAT A MOVE MADE HERE DOES NOT DO. A `cswap switch` run by the deck does not
|
|
19
|
+
// start the engine's cooldown and switches off its guard against undoing its
|
|
20
|
+
// own last move (autoswitch.py `_no_return_account`). An account landed on
|
|
21
|
+
// above the threshold would be left again on the very next tick. So a target
|
|
22
|
+
// is only ever an account comfortably under it, read recently, with no window
|
|
23
|
+
// that has reset since it was read — and the deck keeps a cooldown of its own.
|
|
24
|
+
//
|
|
25
|
+
// Pure: the roster, the rules and the clock in, a target or null out. The loop
|
|
26
|
+
// (cswap-auto-loop.mjs) reads the store, carries out the move and remembers
|
|
27
|
+
// what happened.
|
|
28
|
+
|
|
29
|
+
/** Accounts further than this from their weekly reset are not chased. */
|
|
30
|
+
export const WEEKLY_HORIZON_H = 95;
|
|
31
|
+
/** How far under the threshold a target must sit, so the engine — which
|
|
32
|
+
* leaves an account at the threshold — does not leave it again at once. */
|
|
33
|
+
export const ROOM_MARGIN_PCT = 5;
|
|
34
|
+
/** A reading older than this is not acted on: claude-swap re-reads an idle
|
|
35
|
+
* account every five to ten minutes while the loop runs. */
|
|
36
|
+
export const FRESH_FOR_MS = 15 * 60_000;
|
|
37
|
+
/** Below the threshold, a sooner weekly reset is worth a move only when it is
|
|
38
|
+
* at least this much sooner — so two accounts that reset within the same hour
|
|
39
|
+
* do not trade places. */
|
|
40
|
+
export const WEEKLY_LEAD_S = 3600;
|
|
41
|
+
/** How long after any move the optional ones wait. The engine leaves an
|
|
42
|
+
* account at the threshold even when its 5-hour window resets minutes later;
|
|
43
|
+
* measured in the simulated week, a move straight back followed five minutes
|
|
44
|
+
* on. Nothing optional is worth a move that soon after another. */
|
|
45
|
+
export const SETTLE_MS = 30 * 60_000;
|
|
46
|
+
|
|
47
|
+
/** The two ways to pick the next account. "room" is the engine's own. */
|
|
48
|
+
export const NEXT_CHOICES = Object.freeze(["room", "weekly"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The scoped (per-model) lanes the engine counts, from its `autoswitch.model`
|
|
52
|
+
* setting: none, "all", or a comma list matched case-insensitively against
|
|
53
|
+
* the model's name — the rule autoswitch.py applies to the same windows.
|
|
54
|
+
*/
|
|
55
|
+
export function countedModels(setting) {
|
|
56
|
+
const raw = typeof setting === "string" ? setting.trim() : "";
|
|
57
|
+
if (!raw) return [];
|
|
58
|
+
if (raw.toLowerCase() === "all") return "all";
|
|
59
|
+
return raw.split(",").map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The lanes that decide whether an account is over the threshold: 5h, 7d and
|
|
63
|
+
* whichever model lanes the engine counts. */
|
|
64
|
+
export function bindingLanes(account, models) {
|
|
65
|
+
return (account?.lanes ?? []).filter(l => {
|
|
66
|
+
if (l.id === "five_hour" || l.id === "seven_day") return true;
|
|
67
|
+
if (!String(l.id).startsWith("scoped-")) return false;
|
|
68
|
+
return models === "all" || (Array.isArray(models) && models.includes(String(l.label).toLowerCase()));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The tightest of those lanes, as a percentage used, or null when none. */
|
|
73
|
+
export function usedOf(account, models) {
|
|
74
|
+
const lanes = bindingLanes(account, models);
|
|
75
|
+
return lanes.length ? Math.max(...lanes.map(l => l.pct)) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** When the weekly window resets, in unix seconds, or null when unknown. */
|
|
79
|
+
export function weeklyResetOf(account) {
|
|
80
|
+
const lane = (account?.lanes ?? []).find(l => l.id === "seven_day");
|
|
81
|
+
return typeof lane?.resetAt === "number" ? lane.resetAt : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether this account's numbers may be acted on now: read in the last
|
|
86
|
+
* FRESH_FOR_MS, and no counted window has reset since. A window past its reset
|
|
87
|
+
* still carries the percentage from before it — wrong in the direction that
|
|
88
|
+
* matters least for leaving and most for landing, so it is simply not used
|
|
89
|
+
* until claude-swap has read the account again.
|
|
90
|
+
*/
|
|
91
|
+
export function readingCurrent(account, models, now) {
|
|
92
|
+
if (typeof account?.fetchedAt !== "number" || now - account.fetchedAt > FRESH_FOR_MS) return false;
|
|
93
|
+
return bindingLanes(account, models).every(l => l.resetAt == null || l.resetAt * 1000 > now);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether Auto-switch may land on this account: signed in, in rotation, not
|
|
98
|
+
* the one another paired deck is working on, not one claude-swap has
|
|
99
|
+
* quarantined, read recently, and comfortably under the threshold on every
|
|
100
|
+
* window that counts. An account with no 5-hour or weekly window (an API key)
|
|
101
|
+
* is never a target here; the engine still decides about those.
|
|
102
|
+
*
|
|
103
|
+
* QUARANTINED IS NOT THE SAME AS SIGNED OUT. The engine quarantines an account
|
|
104
|
+
* when the refresh it tries before activating one says the token is dead. Its
|
|
105
|
+
* usage reads go on working until the access token expires, hours later, so
|
|
106
|
+
* the roster still calls it healthy — and `cswap switch`, which is what a move
|
|
107
|
+
* here runs, does not ask the engine.
|
|
108
|
+
*/
|
|
109
|
+
export function landable(account, { threshold, models, busy, quarantined, now }) {
|
|
110
|
+
if (!account || account.active || account.disabled || account.alive === false) return false;
|
|
111
|
+
if (account.error || account.reauth) return false;
|
|
112
|
+
if (busy?.has?.(account.key) || quarantined?.has?.(account.key)) return false;
|
|
113
|
+
const ids = new Set((account.lanes ?? []).map(l => l.id));
|
|
114
|
+
if (!ids.has("five_hour") || !ids.has("seven_day")) return false;
|
|
115
|
+
if (!readingCurrent(account, models, now)) return false;
|
|
116
|
+
return usedOf(account, models) < threshold - ROOM_MARGIN_PCT;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The landable account whose weekly window resets soonest within the horizon
|
|
120
|
+
* (and before `before`, when given), the one with more room first on a tie,
|
|
121
|
+
* then the lower slot — or null. */
|
|
122
|
+
function soonestWeekly(accounts, ctx, before = null) {
|
|
123
|
+
const limit = ctx.now / 1000 + WEEKLY_HORIZON_H * 3600;
|
|
124
|
+
const fits = accounts
|
|
125
|
+
.filter(a => landable(a, ctx))
|
|
126
|
+
.map(a => ({ a, reset: weeklyResetOf(a), used: usedOf(a, ctx.models) }))
|
|
127
|
+
.filter(x => x.reset != null && x.reset * 1000 > ctx.now && x.reset <= limit)
|
|
128
|
+
.filter(x => before == null || x.reset <= before - WEEKLY_LEAD_S)
|
|
129
|
+
.sort((x, y) => x.reset - y.reset || x.used - y.used || x.a.num - y.a.num);
|
|
130
|
+
return fits.length ? fits[0] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Where Auto-switch should move now, or null for "leave it to the engine".
|
|
135
|
+
*
|
|
136
|
+
* accounts roster rows (claude-accounts.mjs), each with its `key`
|
|
137
|
+
* choice { next: "room"|"weekly", home: key|null, manual, goHome }
|
|
138
|
+
* `manual` — the account in use was picked by hand, so the moves
|
|
139
|
+
* nobody needs (below the threshold) leave it alone. `goHome` —
|
|
140
|
+
* somebody just set a home and expects to be taken there.
|
|
141
|
+
* threshold the engine's, in percent
|
|
142
|
+
* models countedModels(...) of the engine's model setting
|
|
143
|
+
* busy keys of the accounts whose login is live somewhere else: on a
|
|
144
|
+
* paired deck, or in a `cswap run` session
|
|
145
|
+
* quarantined keys of the accounts claude-swap's engine has quarantined
|
|
146
|
+
* sinceMove ms since the last move, the engine's or this one's
|
|
147
|
+
* cooldownMs the engine's cooldown, which every move here keeps too
|
|
148
|
+
* optional whether moves below the threshold may be made at all: only
|
|
149
|
+
* while the engine itself is on `best`
|
|
150
|
+
*
|
|
151
|
+
* Returns { to, trigger: "home"|"weekly-reset", leaving, resetAt? }.
|
|
152
|
+
*/
|
|
153
|
+
export function chooseAccount({ accounts, choice, threshold, models = [], busy = new Set(), quarantined = new Set(), sinceMove = Infinity, cooldownMs = 300_000, optional = true, now }) {
|
|
154
|
+
const next = choice?.next === "weekly" ? "weekly" : "room";
|
|
155
|
+
const homeKey = choice?.home ?? null;
|
|
156
|
+
if (next === "room" && !homeKey) return null;
|
|
157
|
+
if (sinceMove < cooldownMs || !Number.isFinite(threshold)) return null;
|
|
158
|
+
|
|
159
|
+
const active = accounts.find(a => a.active);
|
|
160
|
+
// The engine reads the account it is on every minute or so; if the store
|
|
161
|
+
// has nothing current for it, the engine is the one to sort that out.
|
|
162
|
+
if (!active || !readingCurrent(active, models, now)) return null;
|
|
163
|
+
const used = usedOf(active, models);
|
|
164
|
+
if (used == null) return null;
|
|
165
|
+
|
|
166
|
+
const ctx = { threshold, models, busy, quarantined, now };
|
|
167
|
+
const home = homeKey ? accounts.find(a => a.key === homeKey) ?? null : null;
|
|
168
|
+
const onHome = home != null && home === active;
|
|
169
|
+
|
|
170
|
+
// HAS TO LEAVE: the engine would move now. Home first, then the soonest
|
|
171
|
+
// weekly reset within the horizon; when neither fits, the engine picks the
|
|
172
|
+
// account with the most room, as it always has — so nobody is ever stuck.
|
|
173
|
+
if (used >= threshold) {
|
|
174
|
+
if (home && !onHome && landable(home, ctx)) return { to: home, trigger: "home", leaving: true };
|
|
175
|
+
if (next === "weekly") {
|
|
176
|
+
const pick = soonestWeekly(accounts, ctx);
|
|
177
|
+
if (pick) return { to: pick.a, trigger: "weekly-reset", leaving: true, resetAt: pick.reset };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// BELOW THE THRESHOLD every move is optional, and four things stop one: a
|
|
183
|
+
// second strategy in the engine, being home (never left from under the
|
|
184
|
+
// threshold — that is what keeps the two rules from taking turns), a
|
|
185
|
+
// hand-picked account, which stays picked until it fills or a home is set,
|
|
186
|
+
// and a move less than SETTLE_MS ago. A home somebody has just set is the
|
|
187
|
+
// one exception to the last: they asked to be taken there.
|
|
188
|
+
if (!optional || onHome) return null;
|
|
189
|
+
const settled = sinceMove >= Math.max(cooldownMs, SETTLE_MS);
|
|
190
|
+
if (home && (choice.goHome || (!choice.manual && settled)) && landable(home, ctx)) {
|
|
191
|
+
return { to: home, trigger: "home", leaving: false };
|
|
192
|
+
}
|
|
193
|
+
if (choice.manual || next !== "weekly" || !settled) return null;
|
|
194
|
+
const mine = weeklyResetOf(active);
|
|
195
|
+
if (mine == null || mine * 1000 <= now) return null;
|
|
196
|
+
const pick = soonestWeekly(accounts, ctx, mine);
|
|
197
|
+
return pick ? { to: pick.a, trigger: "weekly-reset", leaving: false, resetAt: pick.reset } : null;
|
|
198
|
+
}
|
|
@@ -285,18 +285,24 @@ export function usedBefore(result, fromNumber) {
|
|
|
285
285
|
* "Switched to work@x.com · personal@x.com reached 90%".
|
|
286
286
|
*
|
|
287
287
|
* The reason is stated only when the tick carries it: the account it left had
|
|
288
|
-
* used at least the threshold the policy ran with.
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
288
|
+
* used at least the threshold the policy ran with. Below it, the deck's own two
|
|
289
|
+
* rules say theirs (cswap-auto-choice.mjs) — "home" is a return to the home
|
|
290
|
+
* account, "weekly-reset" a move to the account whose weekly limit resets
|
|
291
|
+
* sooner. A switch for any other reason — the account's usage could not be
|
|
292
|
+
* read — says where it went and from where, and nothing about why.
|
|
292
293
|
*/
|
|
293
|
-
export function swapNotice({ toName, fromName, used, threshold }) {
|
|
294
|
+
export function swapNotice({ toName, fromName, used, threshold, trigger = null, resetAt = null, now = Date.now() }) {
|
|
294
295
|
const to = toName ? cut(toName, NAME_MAX) : "another account";
|
|
295
|
-
|
|
296
|
+
const verb = trigger === "home" ? "Back on" : "Switched to";
|
|
297
|
+
if (!fromName) return { who: SWAP_WHO, body: `${verb} ${to}` };
|
|
296
298
|
const from = cut(fromName, NAME_MAX);
|
|
297
|
-
if (used != null && used >= 100) return { who: SWAP_WHO, body:
|
|
299
|
+
if (used != null && used >= 100) return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached 100%` };
|
|
298
300
|
if (used != null && typeof threshold === "number" && used >= threshold) {
|
|
299
|
-
return { who: SWAP_WHO, body:
|
|
301
|
+
return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached ${pctLabel(threshold)}%` };
|
|
302
|
+
}
|
|
303
|
+
if (trigger === "home") return { who: SWAP_WHO, body: `Back on ${to} · your home account` };
|
|
304
|
+
if (trigger === "weekly-reset" && typeof resetAt === "number" && resetAt * 1000 > now) {
|
|
305
|
+
return { who: SWAP_WHO, body: `Switched to ${to} · weekly limit resets in ${inDuration(resetAt, now)}` };
|
|
300
306
|
}
|
|
301
307
|
return { who: SWAP_WHO, body: `Switched to ${to} from ${from}` };
|
|
302
308
|
}
|
|
@@ -488,6 +494,7 @@ export function createAccountNotifier({
|
|
|
488
494
|
say(swapNotice({
|
|
489
495
|
toName: to?.name ?? null, fromName: from?.name ?? null,
|
|
490
496
|
used: usedBefore(result, result.from?.number), threshold: result.threshold,
|
|
497
|
+
trigger: result.trigger ?? null, resetAt: result.resetAt ?? null, now: at,
|
|
491
498
|
}));
|
|
492
499
|
}
|
|
493
500
|
await persist();
|
|
@@ -348,6 +348,12 @@ export async function handleCswapAutoAction(req, res) {
|
|
|
348
348
|
case "account":
|
|
349
349
|
result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
|
|
350
350
|
break;
|
|
351
|
+
case "next":
|
|
352
|
+
result = await mod.setNextChoice(parsed.value);
|
|
353
|
+
break;
|
|
354
|
+
case "home":
|
|
355
|
+
result = await mod.setHomeAccount(parsed.account ?? null);
|
|
356
|
+
break;
|
|
351
357
|
default:
|
|
352
358
|
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
353
359
|
}
|
package/src/server/activity.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Whether any agent is mid-turn, from
|
|
1
|
+
// Whether any agent is mid-turn, from live events and startup snapshots.
|
|
2
2
|
//
|
|
3
3
|
// For the one decision the server takes on its own that must not land in the
|
|
4
4
|
// middle of a turn: restarting into a new version while nobody is looking
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
// its Stop, and waiting on one forever would mean never updating.
|
|
17
17
|
export const OPEN_CAP_MS = 30 * 60_000;
|
|
18
18
|
|
|
19
|
+
const RECOVERIES = new Set(["CodexSessionRecovered", "ClaudeSessionRecovered"]);
|
|
20
|
+
|
|
21
|
+
/** Persisted history is not live activity; a fresh startup snapshot is. */
|
|
22
|
+
export function shouldTrackActivity(raw, opts = {}) {
|
|
23
|
+
return !opts.replay || RECOVERIES.has(raw?.hook_event_name);
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
const OPENS = new Set([
|
|
20
|
-
"UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
|
|
27
|
+
...RECOVERIES, "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
|
|
21
28
|
]);
|
|
22
29
|
const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
23
30
|
|
|
@@ -40,7 +47,8 @@ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
|
40
47
|
return {
|
|
41
48
|
/** One hook payload, as pushEvent receives it. Anything else is ignored. */
|
|
42
49
|
note(raw, at) {
|
|
43
|
-
const name = raw?.hook_event_name
|
|
50
|
+
const name = raw?.recovery_active === false && RECOVERIES.has(raw?.hook_event_name)
|
|
51
|
+
? "Stop" : raw?.hook_event_name;
|
|
44
52
|
if (!OPENS.has(name) && !CLOSES.has(name)) return;
|
|
45
53
|
if (at > lastAt) lastAt = at;
|
|
46
54
|
const id = raw?.session_id;
|
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
// the same GitLab project — settings, jobs, branches. Fourteen rows
|
|
34
34
|
// describe that worse than one card does. That is what toEpisodes is for.
|
|
35
35
|
// * Without exclusions the deck reports ITSELF: 41 visits to 127.0.0.1:4317
|
|
36
|
-
// and 34 to 127.0.0.1:4399 carry FROM_API,
|
|
37
|
-
// through `open` on every start (bin/deck.js) and the listen fallback range
|
|
36
|
+
// and 34 to 127.0.0.1:4399 carry FROM_API, and the listen fallback range
|
|
38
37
|
// means it is not always the same port. defaultExclusions() is that, and
|
|
39
|
-
// only that.
|
|
38
|
+
// only that. This note used to say `open` was the reason — ccdeck opens its
|
|
39
|
+
// own tab with it on every start (bin/deck.js). Retested on 2026-10-14, a
|
|
40
|
+
// URL that `open` (macOS, Chrome 155) or `xdg-open` (Linux, Brave 1.95)
|
|
41
|
+
// hands to a running Chromium is recorded as AUTO_TOPLEVEL WITHOUT the bit,
|
|
42
|
+
// so those 75 visits most likely came from something else that loaded the
|
|
43
|
+
// deck's page (they were FROM_API over LINK, the shape an extension's
|
|
44
|
+
// tabs.update leaves). The exclusion stands either way: the deck's own
|
|
45
|
+
// origin does collect FROM_API visits.
|
|
40
46
|
//
|
|
41
47
|
// WHAT "NOBODY WAS AT THE BROWSER" MEANS HERE. It is derived from the visit
|
|
42
48
|
// list itself: a human visit is any visit WITHOUT the bit. There is no OS idle
|
|
@@ -65,8 +71,25 @@
|
|
|
65
71
|
// reports program navigation; the person reading the card decides what it was.
|
|
66
72
|
|
|
67
73
|
/** Chrome's PAGE_TRANSITION_FROM_API qualifier — bit 27 of `visits.transition`.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* Chromium defines it only as "originated from an external application", so
|
|
75
|
+
* what sets it is Chrome's own code, and it is narrower than "any API":
|
|
76
|
+
*
|
|
77
|
+
* SET by a page loaded with the DevTools protocol's `Page.navigate`, whatever
|
|
78
|
+
* transition type was asked for (content/browser/devtools/protocol/
|
|
79
|
+
* page_handler.cc), which is how Playwright and Puppeteer go to an address,
|
|
80
|
+
* and by an extension's `chrome.tabs.update` with a URL
|
|
81
|
+
* (chrome/browser/extensions/api/tabs/tabs_api.cc).
|
|
82
|
+
*
|
|
83
|
+
* NOT SET by `Target.createTarget` with a URL (AUTO_TOPLEVEL,
|
|
84
|
+
* chrome/browser/devtools/chrome_devtools_manager_delegate.cc), an
|
|
85
|
+
* extension's `chrome.tabs.create` (LINK, extensions/open_tab_helper.cc), a
|
|
86
|
+
* link a program clicks, or a URL handed over by `open` or `xdg-open`
|
|
87
|
+
* (AUTO_TOPLEVEL, chrome/browser/ui/startup/startup_browser_creator_impl.cc).
|
|
88
|
+
*
|
|
89
|
+
* Tested on 2026-10-14 on Brave 1.95 (Chromium 153, Linux) and Chrome 155
|
|
90
|
+
* (macOS 26.6) with throwaway profiles. The NOT SET ones are invisible to this
|
|
91
|
+
* module, and since a visit without the bit counts as a person (classify),
|
|
92
|
+
* they can also cancel a program's flagged page within the quiet window. */
|
|
70
93
|
export const FROM_API = 0x08000000;
|
|
71
94
|
|
|
72
95
|
/** The same bit for the arbitrary-precision path below. */
|
|
@@ -227,11 +250,11 @@ function hostParts(text) {
|
|
|
227
250
|
* Exclusion matchers for the deck's own pages, from the origins it is listening
|
|
228
251
|
* on — `["http://127.0.0.1:4317"]`, or whatever the fallback range settled on.
|
|
229
252
|
*
|
|
230
|
-
* This is not politeness.
|
|
231
|
-
*
|
|
232
|
-
* exactly where the rule is most sensitive: nobody is
|
|
233
|
-
*
|
|
234
|
-
* frequent finding is the feature itself.
|
|
253
|
+
* This is not politeness. The measured profile held FROM_API visits to the
|
|
254
|
+
* deck's own origin (the header's note says why `open` is not their cause),
|
|
255
|
+
* and those visits land exactly where the rule is most sensitive: nobody is
|
|
256
|
+
* browsing while a program loads the deck's page. Unfiltered, the feature's
|
|
257
|
+
* loudest and most frequent finding is the feature itself.
|
|
235
258
|
*
|
|
236
259
|
* Each origin expands to its port on all three loopback spellings, because the
|
|
237
260
|
* deck opens 127.0.0.1 and a returning user may have typed localhost, and one
|
|
@@ -34,8 +34,9 @@ import { isProcessAlive } from "./deck-probe.mjs";
|
|
|
34
34
|
* `portRange`), so a machine that has been running decks for a month has tabs
|
|
35
35
|
* on several. The real profile this feature was tuned against carried 41 visits
|
|
36
36
|
* to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
|
|
37
|
-
* FROM_API
|
|
38
|
-
* panel would have shown
|
|
37
|
+
* FROM_API (not from `open`, which left no FROM_API when tested: see FROM_API in
|
|
38
|
+
* agent-activity.mjs), and every one of them a card the panel would have shown
|
|
39
|
+
* its owner about itself.
|
|
39
40
|
*
|
|
40
41
|
* The whole range rather than the ports seen: the alternative is to remember
|
|
41
42
|
* which ports past decks used, which is a file to keep, a file to migrate, and
|
|
@@ -664,10 +664,9 @@ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, o
|
|
|
664
664
|
* Everything the panel draws, in one object.
|
|
665
665
|
*
|
|
666
666
|
* `deckOrigins` are the addresses this deck is listening on. They are excluded
|
|
667
|
-
* by default and it is not an optimisation:
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* time it launches.
|
|
667
|
+
* by default and it is not an optimisation: the deck's own origin collects
|
|
668
|
+
* FROM_API visits like any other program's page (see defaultExclusions), so a
|
|
669
|
+
* watch without them reports the deck as the intruder.
|
|
671
670
|
*/
|
|
672
671
|
export async function browserWatchSnapshot({
|
|
673
672
|
deckOrigins = [],
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { claudeConfigDir, ccProjectSlug } from "./claude-dir.mjs";
|
|
4
|
+
import { canonicalCwd } from "./canonical-path.mjs";
|
|
5
|
+
import { codexCwdInWorkspace } from "./log-election.mjs";
|
|
6
|
+
import { readByteRange } from "./jsonl-chunks.mjs";
|
|
7
|
+
import { run } from "./exec.mjs";
|
|
8
|
+
import { heldPrefs, prefsRead } from "./prefs-state.mjs";
|
|
9
|
+
import { pushEvent } from "./event-sink.mjs";
|
|
10
|
+
import { outputWatch, touchSession } from "./session-tracking.mjs";
|
|
11
|
+
import { maybeResolveContext, maybeResolveModel, maybeResolveUsage } from "./session-enrichment.mjs";
|
|
12
|
+
|
|
13
|
+
const FILE_LIMIT = 128;
|
|
14
|
+
const REGISTRY_BYTES = 64 * 1024;
|
|
15
|
+
const TRANSCRIPT_BYTES = 256 * 1024;
|
|
16
|
+
const normalizeStart = value => typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
17
|
+
|
|
18
|
+
/** PID existence alone cannot distinguish a reused PID from the old process. */
|
|
19
|
+
export async function claudeProcessStarts(pids) {
|
|
20
|
+
const starts = new Map();
|
|
21
|
+
// The registry's POSIX lstart fingerprint has no verified Windows equivalent.
|
|
22
|
+
// Unsupported identity formats are deliberately left to normal live hooks.
|
|
23
|
+
if (!["darwin", "linux"].includes(process.platform) || !pids.length) return starts;
|
|
24
|
+
const result = await run("ps", ["-p", pids.join(","), "-o", "pid=,lstart="], {
|
|
25
|
+
timeout: 1500, maxBuffer: 64 * 1024, env: { ...process.env, LC_ALL: "C" },
|
|
26
|
+
});
|
|
27
|
+
starts.complete = result.ok || (result.code === 1 && !result.stderr.trim());
|
|
28
|
+
// ps exits 1 when all candidates are gone; partial output is still useful.
|
|
29
|
+
for (const line of result.stdout.split("\n")) {
|
|
30
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
31
|
+
if (match) starts.set(Number(match[1]), normalizeStart(match[2]));
|
|
32
|
+
}
|
|
33
|
+
return starts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function registry(path) {
|
|
37
|
+
let file;
|
|
38
|
+
try {
|
|
39
|
+
file = await open(path, "r");
|
|
40
|
+
const info = await file.stat();
|
|
41
|
+
if (!info.isFile() || info.size > REGISTRY_BYTES) return null;
|
|
42
|
+
const bytes = Buffer.alloc(info.size);
|
|
43
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
44
|
+
return JSON.parse(bytes.subarray(0, bytesRead).toString("utf8"));
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
finally { await file?.close(); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function claudeRecoveryPrompt(path) {
|
|
50
|
+
try {
|
|
51
|
+
const info = await stat(path);
|
|
52
|
+
const from = Math.max(0, info.size - TRANSCRIPT_BYTES);
|
|
53
|
+
let text = await readByteRange(path, from, info.size);
|
|
54
|
+
if (from) text = text.slice(text.indexOf("\n") + 1);
|
|
55
|
+
const end = text.lastIndexOf("\n");
|
|
56
|
+
if (end < 0) return {};
|
|
57
|
+
let prompt, model;
|
|
58
|
+
for (const line of text.slice(0, end).split("\n")) {
|
|
59
|
+
let row;
|
|
60
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
61
|
+
if (row?.isSidechain) continue;
|
|
62
|
+
if (row?.type === "assistant" && typeof row.message?.model === "string") model = row.message.model;
|
|
63
|
+
if (row?.type !== "user") continue;
|
|
64
|
+
const content = row.message?.content;
|
|
65
|
+
const value = typeof content === "string" ? content : Array.isArray(content)
|
|
66
|
+
? content.filter(part => part?.type === "text" && typeof part.text === "string").map(part => part.text).join("\n") : "";
|
|
67
|
+
if (value) prompt = value;
|
|
68
|
+
}
|
|
69
|
+
return { prompt, model };
|
|
70
|
+
} catch { return {}; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bounded discovery plus status reconciliation for the sessions it recovered. */
|
|
74
|
+
export function createClaudeRecovery({ workspace = "", home = claudeConfigDir(),
|
|
75
|
+
platform = process.platform, starts = claudeProcessStarts, emit = pushEvent,
|
|
76
|
+
enrich = raw => {
|
|
77
|
+
outputWatch.note(raw.session_id, raw.transcript_path);
|
|
78
|
+
touchSession(raw.session_id);
|
|
79
|
+
maybeResolveModel(raw); maybeResolveUsage(raw); maybeResolveContext(raw);
|
|
80
|
+
}, enabled = () => heldPrefs.current().recoverClaudeSessions !== false } = {}) {
|
|
81
|
+
let stopped = false, pending = null, startupEnabled, startupFiles;
|
|
82
|
+
const startupIdentities = new Map();
|
|
83
|
+
const tracked = new Map();
|
|
84
|
+
async function scanOnce() {
|
|
85
|
+
await prefsRead;
|
|
86
|
+
startupEnabled ??= enabled();
|
|
87
|
+
if (stopped || !startupEnabled) return;
|
|
88
|
+
const observedAt = Date.now();
|
|
89
|
+
let files;
|
|
90
|
+
try {
|
|
91
|
+
startupFiles ??= (await readdir(join(home, "sessions"))).filter(name => /^\d+\.json$/.test(name)).sort().slice(0, FILE_LIMIT);
|
|
92
|
+
files = startupFiles;
|
|
93
|
+
}
|
|
94
|
+
catch { return; }
|
|
95
|
+
const candidates = [];
|
|
96
|
+
for (const name of files) {
|
|
97
|
+
const path = join(home, "sessions", name);
|
|
98
|
+
const row = await registry(path);
|
|
99
|
+
if (!row || row.pid !== Number(name.slice(0, -5)) || !Number.isSafeInteger(row.pid) || row.pid <= 0 ||
|
|
100
|
+
row.pidDomain !== platform || !normalizeStart(row.procStart) ||
|
|
101
|
+
typeof row.sessionId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(row.sessionId) ||
|
|
102
|
+
typeof row.cwd !== "string" || !row.cwd) continue;
|
|
103
|
+
const identity = `${row.pid}:${row.procStart}:${row.sessionId}`;
|
|
104
|
+
if (startupIdentities.has(path) && startupIdentities.get(path) !== identity) continue;
|
|
105
|
+
startupIdentities.set(path, identity);
|
|
106
|
+
const cwd = await canonicalCwd(row.cwd);
|
|
107
|
+
if (!codexCwdInWorkspace(cwd, workspace)) continue;
|
|
108
|
+
candidates.push({ path, row, cwd });
|
|
109
|
+
}
|
|
110
|
+
const identities = await starts([...new Set([...candidates.map(c => c.row.pid), ...[...tracked.values()].filter(t => t.active).map(t => t.pid)])]);
|
|
111
|
+
for (const [sid, prior] of tracked) {
|
|
112
|
+
if (!prior.active || stopped) continue;
|
|
113
|
+
const actual = identities.get(prior.pid);
|
|
114
|
+
if ((actual && actual !== normalizeStart(prior.procStart)) || (!actual && identities.complete === true)) {
|
|
115
|
+
emit({ hook_event_name: "ClaudeSessionRecovered", provider: "claude", session_id: sid,
|
|
116
|
+
cwd: prior.cwd, snapshot_observed_at: observedAt, recovery_active: false, recovery_session_closed: true,
|
|
117
|
+
}, "hook", { replay: true, persist: false });
|
|
118
|
+
prior.active = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
candidates.sort((a, b) => (Number(b.row.updatedAt) || 0) - (Number(a.row.updatedAt) || 0));
|
|
122
|
+
const selected = new Set();
|
|
123
|
+
for (const { path, row, cwd } of candidates) {
|
|
124
|
+
if (stopped) return;
|
|
125
|
+
if (identities.get(row.pid) !== normalizeStart(row.procStart)) continue;
|
|
126
|
+
if (selected.has(row.sessionId)) continue;
|
|
127
|
+
selected.add(row.sessionId);
|
|
128
|
+
const previous = tracked.get(row.sessionId);
|
|
129
|
+
if (row.status !== "busy" && row.status !== "idle") continue;
|
|
130
|
+
const active = row.status === "busy";
|
|
131
|
+
if (previous?.active === active) continue;
|
|
132
|
+
const transcript = join(home, "projects", ccProjectSlug(row.cwd), `${row.sessionId}.jsonl`);
|
|
133
|
+
const facts = active ? await claudeRecoveryPrompt(transcript) : {};
|
|
134
|
+
// A hook/status update can overtake the transcript read.
|
|
135
|
+
const latest = await registry(path);
|
|
136
|
+
if (!latest || latest.sessionId !== row.sessionId || latest.pid !== row.pid ||
|
|
137
|
+
latest.procStart !== row.procStart || latest.status !== row.status || latest.updatedAt !== row.updatedAt) continue;
|
|
138
|
+
if (stopped) return;
|
|
139
|
+
emit({ hook_event_name: "ClaudeSessionRecovered", provider: "claude", session_id: row.sessionId,
|
|
140
|
+
cwd, snapshot_observed_at: observedAt, recovery_active: active, ...facts,
|
|
141
|
+
}, "hook", { replay: true, persist: false });
|
|
142
|
+
tracked.set(row.sessionId, { active, pid: row.pid, procStart: row.procStart, cwd });
|
|
143
|
+
if (active) enrich({ session_id: row.sessionId, cwd, transcript_path: transcript });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
scan() { return pending ??= scanOnce().finally(() => { pending = null; }); },
|
|
148
|
+
stop() { stopped = true; },
|
|
149
|
+
};
|
|
150
|
+
}
|