ccdeck 3.42.0 โ 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 +35 -17
- 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 +5 -2
- package/package.json +1 -1
- package/release-notes.json +22 -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/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 +113 -9
- 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 +4 -3
- package/src/server/evicted-enrichment.mjs +2 -0
- package/src/server/index.mjs +9 -0
- package/src/server/pinned-build.mjs +4 -0
- package/src/server/traffic-radar-config.mjs +8 -1
- package/dist/web/assets/BrowserWatchModal-TdSBv7_T.js +0 -1
- package/dist/web/assets/RadioPanel-bJc004IK.js +0 -1
- package/dist/web/assets/TrafficRadar-pRTWTOpJ.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BBAEDRj9.js +0 -1
- package/dist/web/assets/index-BPLXy9SO.css +0 -1
- package/dist/web/assets/index-DzDnLqDX.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
|
@@ -77,10 +77,13 @@
|
|
|
77
77
|
document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
|
|
78
78
|
})();
|
|
79
79
|
</script>
|
|
80
|
-
<script type="module" crossorigin src="/assets/index-
|
|
81
|
-
<link rel="
|
|
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">
|
|
82
83
|
</head>
|
|
83
84
|
<body>
|
|
84
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). -->
|
|
85
88
|
</body>
|
|
86
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,28 @@
|
|
|
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
|
+
],
|
|
81
103
|
"3.42.0": [
|
|
82
104
|
{
|
|
83
105
|
"title": "๐ Text size and density, in Settings",
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -18,8 +18,8 @@ import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
|
|
|
18
18
|
// reads a rollout's lines by, the ones the translation reads โ see
|
|
19
19
|
// codex-translate.mjs.
|
|
20
20
|
import {
|
|
21
|
-
codexSessionApproval, codexSessionModel,
|
|
22
|
-
responseItemModel, sessionMeta, taskStartedWindow, tokenCountInfo,
|
|
21
|
+
codexSessionApproval, codexSessionEffort, codexSessionModel,
|
|
22
|
+
responseItemModel, sessionMeta, taskStartedWindow, tokenCountInfo, turnContextEffort,
|
|
23
23
|
} from "./codex-translate.mjs";
|
|
24
24
|
// The bounded chunk reads the Claude transcripts use โ see jsonl-chunks.mjs.
|
|
25
25
|
import { readByteRange } from "./jsonl-chunks.mjs";
|
|
@@ -227,6 +227,8 @@ export async function readCodexRollout(path) {
|
|
|
227
227
|
let model = null;
|
|
228
228
|
let contextWindow = null;
|
|
229
229
|
let cwd = null;
|
|
230
|
+
// Undefined until a turn_context is read: "no turn yet" is not "no effort".
|
|
231
|
+
let effort;
|
|
230
232
|
for (const line of text.split("\n")) {
|
|
231
233
|
if (!line) continue;
|
|
232
234
|
let obj;
|
|
@@ -245,9 +247,11 @@ export async function readCodexRollout(path) {
|
|
|
245
247
|
// Fallback model source โ response items carry the model id.
|
|
246
248
|
const itemModel = responseItemModel(obj);
|
|
247
249
|
if (itemModel !== null) model = itemModel;
|
|
250
|
+
// The newest turn's effort (#2155), as the rollout watcher reads it.
|
|
251
|
+
if (obj?.type === "turn_context") effort = turnContextEffort(obj.payload);
|
|
248
252
|
}
|
|
249
253
|
if (!lastUsage && !model && !contextWindow) return null;
|
|
250
|
-
return { usage: lastUsage, model, contextWindow, cwd };
|
|
254
|
+
return { usage: lastUsage, model, contextWindow, cwd, effort };
|
|
251
255
|
} catch {
|
|
252
256
|
return null;
|
|
253
257
|
}
|
|
@@ -275,6 +279,9 @@ function maybeResolveCodex(payload) {
|
|
|
275
279
|
hook_event_name: "ModelObserved",
|
|
276
280
|
session_id: sid,
|
|
277
281
|
model: r.model,
|
|
282
|
+
// A hook-delivered session has no watcher to spread it, so it rides
|
|
283
|
+
// here, beside the model it qualifies.
|
|
284
|
+
...(r.effort !== undefined ? { reasoning_effort: r.effort } : {}),
|
|
278
285
|
}, "internal");
|
|
279
286
|
}
|
|
280
287
|
if (r.contextWindow) {
|
|
@@ -300,6 +307,7 @@ function forgetCodexSession(sid) {
|
|
|
300
307
|
codexUsageReads.forget(sid);
|
|
301
308
|
codexSessionModel.delete(sid);
|
|
302
309
|
codexSessionApproval.delete(sid);
|
|
310
|
+
codexSessionEffort.delete(sid);
|
|
303
311
|
}
|
|
304
312
|
|
|
305
313
|
// What event-pipeline.mjs and session-tracking.mjs call besides readCodexRollout
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Read only explicit thread relationships. A model's prose is never ancestry.
|
|
2
|
+
export function codexThreadLink(record, sessionId, cwd) {
|
|
3
|
+
const text = value => typeof value === "string" && value.trim() ? value : null;
|
|
4
|
+
let child, parent, details;
|
|
5
|
+
if (record?.type === "session_meta") {
|
|
6
|
+
details = record.payload;
|
|
7
|
+
const spawn = details?.source?.subagent?.thread_spawn;
|
|
8
|
+
const direct = text(details?.parent_thread_id);
|
|
9
|
+
const inherited = text(spawn?.parent_thread_id);
|
|
10
|
+
if (direct && inherited && direct !== inherited) return null;
|
|
11
|
+
parent = direct ?? inherited;
|
|
12
|
+
child = text(details?.id) ?? text(sessionId);
|
|
13
|
+
details = { ...spawn, ...details };
|
|
14
|
+
} else if (record?.type === "event_msg") {
|
|
15
|
+
const payload = record.payload;
|
|
16
|
+
details = payload?.type === "sub_agent_activity" ? payload
|
|
17
|
+
: payload?.item?.type === "SubAgentActivity" ? payload.item : null;
|
|
18
|
+
if (details?.kind !== "started") return null;
|
|
19
|
+
child = text(details.agent_thread_id);
|
|
20
|
+
parent = text(sessionId);
|
|
21
|
+
}
|
|
22
|
+
if (!child || !parent || child === parent) return null;
|
|
23
|
+
return {
|
|
24
|
+
hook_event_name: "CodexThreadLinked", provider: "codex",
|
|
25
|
+
session_id: child, parent_session_id: parent, cwd,
|
|
26
|
+
agent_nickname: text(details?.agent_nickname),
|
|
27
|
+
agent_role: text(details?.agent_role) ?? text(details?.agent_type),
|
|
28
|
+
};
|
|
29
|
+
}
|