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