ccdeck 3.41.3 → 3.42.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.
@@ -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,8 +66,19 @@
66
66
  document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
67
67
  })();
68
68
  </script>
69
- <script type="module" crossorigin src="/assets/index-C80Hp_2s.js"></script>
70
- <link rel="stylesheet" crossorigin href="/assets/index-BgSnAnP6.css">
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-DzDnLqDX.js"></script>
81
+ <link rel="stylesheet" crossorigin href="/assets/index-BPLXy9SO.css">
71
82
  </head>
72
83
  <body>
73
84
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.41.3",
3
+ "version": "3.42.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": {
@@ -78,6 +78,24 @@
78
78
  "3.40.1",
79
79
  "3.41.3"
80
80
  ],
81
+ "3.42.0": [
82
+ {
83
+ "title": "🔠 Text size and density, in Settings",
84
+ "body": "Five text sizes and five spacing levels, under Settings › Text & density."
85
+ },
86
+ {
87
+ "title": "🛠️ Tool activity: which tools your agents use, how long they take and what fails",
88
+ "body": "A new page on the stripe on the right. Its history is kept on this device only."
89
+ },
90
+ {
91
+ "title": "🎨 Gruvbox Dark, Light and Material themes",
92
+ "body": "Pick one in Settings › General. Switching themes now fades instead of snapping."
93
+ },
94
+ {
95
+ "title": "✨ A session-list drawer on phones, whole dates in usage history, the waiting session in the minimap",
96
+ "body": "Smaller fixes for the phone layout, the usage history chart and the canvas minimap."
97
+ }
98
+ ],
81
99
  "3.41.1": [
82
100
  {
83
101
  "title": "🪟 More reliable Windows updates",
@@ -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, because ccdeck opens its own tab
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
- * Set when the navigation was started through an API rather than by a person
69
- * in the UI, which covers `open`, the debugger protocol, and an extension. */
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. ccdeck opens its own tab through `open`, so every
231
- * start writes a FROM_API visit to the deck's own origin, and those visits land
232
- * exactly where the rule is most sensitive: nobody is browsing at the moment a
233
- * background process opens a tab. Unfiltered, the feature's loudest and most
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 because `open` is an API call, and every one of them a card the
38
- * panel would have shown its owner about itself.
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: ccdeck opens its own tab through
668
- * `open` on every start, which Chrome records with the same FROM_API bit as any
669
- * other program, so a watch without them reports the deck as the intruder every
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 = [],
@@ -244,8 +244,8 @@ async function readCodexHeader(path) {
244
244
  // write it to the log they share. The event is still buffered and broadcast —
245
245
  // every deck watching the session draws it — it is only the second copy on disk
246
246
  // that is dropped. See writesCodexLog in log-election.mjs.
247
- function emitCodexEvent(payload, persist) {
248
- pushEvent(payload, "codex", { persist });
247
+ function emitCodexEvent(payload, persist, toolAuditAt) {
248
+ pushEvent(payload, "codex", { persist, toolAuditAt });
249
249
  }
250
250
 
251
251
  /**
@@ -405,7 +405,7 @@ function emitCodexLines(state, consume, persist) {
405
405
  }
406
406
  if (payload) {
407
407
  ensureCodexRoot(state, persist);
408
- emitCodexEvent(payload, persist);
408
+ emitCodexEvent(payload, persist, typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : undefined);
409
409
  }
410
410
  }
411
411
  }
@@ -9,6 +9,7 @@
9
9
  // imports reach pushEvent through event-sink.mjs, connected below as this
10
10
  // module loads; index.mjs's routes import it from here. The bodies are
11
11
  // unchanged.
12
+ import { noteToolAudit } from "./tool-audit.mjs";
12
13
  import { PRODUCT } from "./brand.mjs";
13
14
  import { createBlockNotifier } from "./block-notify.mjs";
14
15
  import { notify as osNotify } from "./browser-react.mjs";
@@ -122,6 +123,7 @@ export function pushEvent(raw, source, opts = {}) {
122
123
  // Numbered, charged, held, and the ring evicted behind it — see admitEvent.
123
124
  const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
124
125
  const seq = evt.seq;
126
+ noteToolAudit(raw, source, opts);
125
127
 
126
128
  // Does this event reach the log at all? Not on a replay (it came from
127
129
  // there), not when the hook told us another deck owns this session's log,
@@ -139,6 +139,7 @@ export function upstreamForm({ fields, images }, facts) {
139
139
  if (contact) text("contact", contact);
140
140
  text("appVersion", facts.appVersion);
141
141
  text("platform", facts.platform);
142
+ if (facts.deviceId) text("deviceId", facts.deviceId);
142
143
  for (const [index, { data, format }] of images.entries()) {
143
144
  const { type, extension } = FORMAT[format];
144
145
  parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
@@ -164,6 +164,8 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
164
164
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
165
165
  import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
166
166
  // The usage panel's quota and history reads — see usage-routes.mjs.
167
+ import { startToolAudit } from "./tool-audit.mjs";
168
+ import { handleToolAudit } from "./tool-audit-routes.mjs";
167
169
  import { startQuotaHistory } from "./quota-history.mjs";
168
170
  import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
169
171
  // The port fallback, one listen attempt, and the words for a failed one — see
@@ -192,6 +194,7 @@ export function requestUrl(rawUrl) {
192
194
 
193
195
  export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
194
196
  startQuotaHistory();
197
+ await startToolAudit();
195
198
  armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
196
199
  // Which tree and which CLIs, normalised once — see setDeckScope.
197
200
  setDeckScope({ workspace, claude, codex });
@@ -318,6 +321,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
318
321
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
319
322
  if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
320
323
  if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
324
+ if (["GET", "POST"].includes(req.method) && ["/api/tool-audit", "/api/tool-audit/storage"].includes(url.pathname)) return guard(handleToolAudit(req, res), res);
321
325
  if (req.method === "GET" && url.pathname === "/api/quota/history") return guard(handleQuotaHistory(req, res), res);
322
326
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
323
327
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
@@ -53,6 +53,7 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
53
53
  */
54
54
  const PINNED_MODULES = [
55
55
  "self-update.mjs",
56
+ "tool-audit.mjs",
56
57
  "claude-accounts.mjs",
57
58
  "account-health.mjs",
58
59
  "cswap-admin.mjs",
@@ -7,7 +7,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
7
7
  import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
8
8
  import { readBody, readBytes, send } from "./http-io.mjs";
9
9
  import { heldPrefs } from "./prefs-state.mjs";
10
- import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
10
+ import { REPORTS_API, deviceIdToken, installFacts, reporter, reportsOn } from "./reports.mjs";
11
11
 
12
12
  async function readJson(req, res, limit) {
13
13
  const raw = await readBody(req, res, limit).catch(() => null);
@@ -72,35 +72,54 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
72
72
  * one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
73
73
  * that it "turns off everything but the quota reads", and a launch script's
74
74
  * word about the machine outranks a button on the page.
75
+ *
76
+ * `device` is the fingerprint feedbackDeviceId picks; a test passes its own,
77
+ * or null for none.
75
78
  */
76
- export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
79
+ export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env, device = feedbackDeviceId({ env }) } = {}) {
77
80
  if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
81
+ // Left out of the post entirely when there is none: JSON drops an undefined.
82
+ const deviceId = device || undefined;
78
83
  const contentType = req.headers?.["content-type"];
79
- if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
84
+ if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId });
80
85
  const body = await readJson(req, res, 32_000);
81
86
  if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
82
87
  const facts = feedbackFacts({ env });
83
88
  const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
84
89
  return forwardFeedback(res, fetchImpl, {
85
90
  headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
86
- body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
91
+ body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts, deviceId }),
87
92
  timeoutMs: JSON_TIMEOUT_MS,
88
93
  });
89
94
  }
90
95
 
91
96
  /**
92
- * What this deck adds to a piece of feedback, and all it adds: the ccdeck
93
- * version, and the system as `os-arch` — `darwin-arm64`. installFacts knows
94
- * more than that (the channel, the runtime, a locale, a device fingerprint)
95
- * and none of it goes with feedback. One function for both posts and for the
96
- * line the dialog draws before Send, so what the page says is sent and what
97
- * the API is sent are one answer and cannot drift apart.
97
+ * What the dialog says, before Send, that this deck adds to a piece of
98
+ * feedback: the ccdeck version, and the system as `os-arch` — `darwin-arm64`.
99
+ * Both posts carry these two, from this one function, so what the line shows
100
+ * and what the API gets for them cannot drift apart.
101
+ *
102
+ * The posts also carry the device fingerprint (feedbackDeviceId), and the line
103
+ * does not show it: the owner's decision of 2026-10-14, so a report can be
104
+ * tied to the install that sent it. installFacts' other fields (the channel,
105
+ * the runtime, a locale) never go with feedback.
98
106
  */
99
107
  export function feedbackFacts({ env = process.env } = {}) {
100
108
  const facts = installFacts({ env });
101
109
  return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
102
110
  }
103
111
 
112
+ /**
113
+ * The device fingerprint a piece of feedback carries: the same one-way hash
114
+ * every usage report sends (deviceIdToken), and under the same rule — only
115
+ * while reports are on. Switched off, or vetoed with AGENTS_DECK_NO_REPORTS=1,
116
+ * the fingerprint stays home here as it does everywhere else, and the feedback
117
+ * still goes. It is read here, never taken from the page.
118
+ */
119
+ export function feedbackDeviceId({ env = process.env, prefs = heldPrefs.current() } = {}) {
120
+ return reportsOn(prefs, env) ? deviceIdToken() : undefined;
121
+ }
122
+
104
123
  /** `GET /api/feedback`: what a report sent from here would carry besides its
105
124
  * words, for the dialog to say before Send. Nothing leaves the machine. */
106
125
  export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
@@ -113,7 +132,7 @@ const JSON_TIMEOUT_MS = 20_000;
113
132
  * seconds of upload before the API has read a byte of it. */
114
133
  const FORM_TIMEOUT_MS = 120_000;
115
134
 
116
- async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
135
+ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId }) {
117
136
  // Over the cap, readBytes has already answered 413 too_large and the 400
118
137
  // below is a no-op; this is the answer to a read that failed some other way.
119
138
  const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
@@ -125,7 +144,7 @@ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
125
144
  return forwardFeedback(res, fetchImpl, {
126
145
  // No content type: fetch writes the form's own, boundary and all.
127
146
  headers: { "user-agent": `ccdeck/${facts.appVersion}` },
128
- body: upstreamForm(form, facts),
147
+ body: upstreamForm(form, { ...facts, deviceId }),
129
148
  timeoutMs: FORM_TIMEOUT_MS,
130
149
  });
131
150
  }
@@ -430,6 +430,8 @@ export function isAuthorizedDataRead(req) {
430
430
  * costs nothing: every fetch of both is in src/web, and a page's own GET
431
431
  * carries Sec-Fetch-Site: same-origin. */
432
432
  export const GUARDED_READS = new Set([
433
+ "/api/tool-audit",
434
+ "/api/tool-audit/storage",
433
435
  "/api/system/traffic-radar",
434
436
  "/api/system/traffic-radar/capture",
435
437
  "/api/system/traffic-radar/capture/event",
@@ -0,0 +1,112 @@
1
+ // Classify shell operations without evaluating shell syntax or retaining arguments.
2
+ const safe = value => typeof value === 'string' && /^[A-Za-z0-9_./:+-]{1,100}$/.test(value) ? value : null;
3
+ const basename = value => value.split(/[\\/]/).at(-1).replace(/\.exe$/i, '');
4
+ const takesValue = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
5
+
6
+ function shellWords(command) {
7
+ const segments = [[]]; let token = '', quote = '', escaped = false;
8
+ const word = () => { if (token) segments.at(-1).push(token); token = ''; };
9
+ for (const ch of command.slice(0, 32768)) {
10
+ if (escaped) { token += ch; escaped = false; continue; }
11
+ if (ch === '\\' && quote !== "'") { escaped = true; continue; }
12
+ if (quote) { if (ch === quote) quote = ''; else token += ch; continue; }
13
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
14
+ if (/\s/.test(ch)) { if (ch === '\n') { word(); segments.push([]); } else word(); }
15
+ else if (';&|'.includes(ch)) { word(); if (segments.at(-1).length) segments.push([]); }
16
+ else token += ch;
17
+ }
18
+ word();
19
+ return { segments: segments.filter(s => s.length), uncertain: !!quote || escaped || /\$\(|`|<<|\b(?:if|for|while|case|function)\b/.test(command) };
20
+ }
21
+ function operation(words, depth = 0) {
22
+ let i = 0;
23
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++;
24
+ if (words[i] === 'env') { i++; while (words[i]?.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++; }
25
+ if (words[i] === 'sudo') { i++; if (words[i] === '-u') i += 2; }
26
+ if (words[i] === 'timeout') { i++; while (words[i]?.startsWith('-')) i++; i++; }
27
+ const exe = safe(words[i]); if (!exe) return 'shell (unclassified)';
28
+ const program = basename(exe); const args = words.slice(i + 1);
29
+ if (['sh', 'bash', 'zsh', 'pwsh', 'powershell'].includes(program) && depth < 2) {
30
+ const at = args.findIndex(a => /^-(?:[a-z]*c|Command)$/i.test(a));
31
+ if (at >= 0 && args[at + 1]) return shellOperations(args[at + 1], depth + 1);
32
+ }
33
+ if (program === 'git') {
34
+ let j = 0;
35
+ while (args[j]?.startsWith('-')) { const a = args[j++]; if (takesValue.has(a)) j++; }
36
+ const sub = safe(args[j]); return sub && /^[a-z][a-z-]*$/.test(sub) ? `git ${sub}` : 'git';
37
+ }
38
+ if (['npm', 'pnpm', 'yarn', 'bun', 'dotnet', 'cargo', 'docker', 'kubectl', 'gh', 'uv'].includes(program)) {
39
+ const sub = args.find(a => !a.startsWith('-'));
40
+ if (!safe(sub) || !/^[a-z][a-z-]*$/.test(sub)) return program;
41
+ const at = args.indexOf(sub);
42
+ if (['run', 'run-script'].includes(sub) && safe(args[at + 1])) return `${program} run ${args[at + 1]}`;
43
+ if (program === 'docker' && ['compose', 'container', 'image'].includes(sub) && /^[a-z][a-z-]*$/.test(args[at + 1] ?? '')) return `${program} ${sub} ${args[at + 1]}`;
44
+ return `${program} ${sub}`;
45
+ }
46
+ return safe(program) ?? 'shell (unclassified)';
47
+ }
48
+ export function shellOperations(command, depth = 0) {
49
+ const { segments, uncertain } = shellWords(command);
50
+ if (uncertain || !segments.length || segments.length > 32) return ['shell (unclassified)'];
51
+ return [...new Set(segments.flatMap(words => operation(words, depth)))].slice(0, 16);
52
+ }
53
+ export function auditOperations(name, input) {
54
+ const tool = safe(name) ?? 'tool (unclassified)';
55
+ if (['exec', 'functions.exec'].includes(name) && typeof input?.script === 'string') return { tool, operations: scriptOperations(input.script), shell: true, wrapped: true };
56
+ if (/^(?:Bash|Shell|exec_command|run|shell_command|functions\.exec_command|mcp__.*__exec_command)$/i.test(name ?? '')) {
57
+ const command = input?.command ?? input?.cmd;
58
+ if (typeof command === 'string' && command.length <= 32768) return { tool, operations: shellOperations(command), shell: true };
59
+ return { tool, operations: ['shell (unclassified)'], shell: true };
60
+ }
61
+ return { tool, operations: [tool], shell: false };
62
+ }
63
+
64
+ // Tokenize the wrapper without running it. Strings/comments cannot masquerade
65
+ // as tools calls; only literal cmd values are classified as shell operations.
66
+ export function scriptOperations(script) {
67
+ if (typeof script !== 'string' || script.length > 32768) return ['exec (unclassified)'];
68
+ const tokens = []; let i = 0;
69
+ while (i < script.length) {
70
+ const c = script[i];
71
+ if (/\s/.test(c)) { i++; continue; }
72
+ if (script.slice(i, i + 2) === '//') { i = script.indexOf('\n', i); if (i < 0) break; continue; }
73
+ if (script.slice(i, i + 2) === '/*') { const end = script.indexOf('*/', i + 2); if (end < 0) break; i = end + 2; continue; }
74
+ if (['"', "'", '`'].includes(c)) {
75
+ const quote = c; i++; let value = '', valid = true, closed = false;
76
+ while (i < script.length) {
77
+ const ch = script[i++];
78
+ if (ch === quote) { closed = true; break; }
79
+ if (quote === '`' && ch === '$' && script[i] === '{') valid = false;
80
+ if (ch !== '\\') { value += ch; continue; }
81
+ const escaped = script[i++];
82
+ if (escaped === undefined) { valid = false; break; }
83
+ const simple = { n: '\n', r: '\r', t: '\t', '\\': '\\', '"': '"', "'": "'", '`': '`' };
84
+ if (Object.hasOwn(simple, escaped)) value += simple[escaped];
85
+ else if (escaped === '\n') continue;
86
+ else valid = false;
87
+ }
88
+ tokens.push({ kind: 'string', value: valid && closed ? value : null }); continue;
89
+ }
90
+ const id = /^[A-Za-z_$][\w$]*/.exec(script.slice(i));
91
+ if (id) { tokens.push({ kind: 'id', value: id[0] }); i += id[0].length; }
92
+ else { tokens.push({ kind: 'punct', value: c }); i++; }
93
+ }
94
+ const operations = [];
95
+ for (let j = 0; j < tokens.length - 4; j++) {
96
+ if (tokens[j].kind !== 'id' || tokens[j].value !== 'tools' || tokens[j + 1].value !== '.' || tokens[j + 2].kind !== 'id' || tokens[j + 3].value !== '(') continue;
97
+ const method = tokens[j + 2].value;
98
+ if (method !== 'exec_command') { operations.push(safe(method) ?? 'tool (unclassified)'); continue; }
99
+ let cmd = null, depth = 0;
100
+ if (tokens[j + 4].value === '{') {
101
+ for (let k = j + 4; k < tokens.length; k++) {
102
+ const t = tokens[k];
103
+ if (t.kind === 'punct' && t.value === '{') depth++;
104
+ if (t.kind === 'punct' && t.value === '}' && --depth <= 0) break;
105
+ if (depth === 1 && t.value === 'cmd' && ['id', 'string'].includes(t.kind) && tokens[k + 1]?.value === ':' && tokens[k + 2]?.kind === 'string'
106
+ && [',', '}'].includes(tokens[k + 3]?.value)) { cmd = tokens[k + 2].value; break; }
107
+ }
108
+ }
109
+ operations.push(...(cmd === null ? ['shell (unclassified)'] : shellOperations(cmd)));
110
+ }
111
+ return operations.length ? [...new Set(operations)].slice(0, 16) : ['exec (unclassified)'];
112
+ }
@@ -0,0 +1,115 @@
1
+ // Runs in a worker: complete local journals, not the canvas's bounded call list.
2
+ export async function buildToolAuditReport({ files, query, now, meta }, { createReadStream, createInterface }) {
3
+ const DAY = 86400000;
4
+ const finite = n => typeof n === 'number' && Number.isFinite(n);
5
+ const valid = r => /^[a-f0-9]{64}$/.test(r?.id ?? '') && ['claude', 'codex'].includes(r.provider)
6
+ && ['start', 'end'].includes(r.kind) && finite(r.t) && (r.at === null || finite(r.at))
7
+ && ['hook', 'rollout'].includes(r.clock)
8
+ && (r.kind === 'end' ? typeof r.failed === 'boolean' : typeof r.tool === 'string' && r.tool.length <= 100
9
+ && Array.isArray(r.operations) && r.operations.length > 0 && r.operations.length <= 16
10
+ && r.operations.every(s => typeof s === 'string' && s.length <= 150));
11
+ const percentile = (sorted, p) => sorted.length ? sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] : null;
12
+ const calls = new Map(); let corrupt = 0, duplicates = 0;
13
+ // Explicit failure rather than a convincing-looking partial report.
14
+ for (const file of files) {
15
+ if (file.size > 64 * 1024 * 1024) throw new Error('Daily tool history exceeds report capacity');
16
+ if (!file.size) continue;
17
+ const stream = createReadStream(file.path, { start: 0, end: file.size - 1, encoding: 'utf8' });
18
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
19
+ try {
20
+ for await (const line of lines) {
21
+ if (!line) continue;
22
+ let r; try { r = JSON.parse(line); } catch { corrupt++; continue; }
23
+ if (!valid(r)) { corrupt++; continue; }
24
+ let call = calls.get(r.id);
25
+ if (!call) { if (calls.size >= 500000) throw new Error('Tool history exceeds report capacity; choose shorter retention in Settings'); call = {}; calls.set(r.id, call); }
26
+ if (call[r.kind]) { duplicates++; continue; }
27
+ call[r.kind] = r;
28
+ }
29
+ } finally { lines.close(); stream.destroy(); }
30
+ }
31
+ const since = query.since ?? now - (query.days ?? 7) * DAY;
32
+ const until = query.until ?? now;
33
+ const retainedSince = Math.max(meta.startedAt, Math.floor(now / DAY) * DAY - (meta.retentionDays - 1) * DAY);
34
+ const daily = new Map();
35
+ const dayFormatter = query.history ? new Intl.DateTimeFormat('en-CA', { timeZone: query.timeZone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) : null;
36
+ const dayKey = time => {
37
+ const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
38
+ return `${parts.year}-${parts.month}-${parts.day}`;
39
+ };
40
+ // Calendar labels advance independently of offsets: DST days may be 23 or 25 hours.
41
+ if (query.history && query.operation) {
42
+ const first = Date.parse(dayKey(since)), last = Date.parse(dayKey(until));
43
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last - first > 91 * DAY) throw new Error('Daily history exceeds report capacity');
44
+ const retainedDay = dayKey(retainedSince);
45
+ const atBoundary = (time, end = false) => {
46
+ const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
47
+ return parts.hour === (end ? '23' : '00') && parts.minute === (end ? '59' : '00') && parts.second === (end ? '59' : '00') && ((time % 1000) + 1000) % 1000 === (end ? 999 : 0);
48
+ };
49
+ const firstDay = dayKey(since), lastDay = dayKey(until);
50
+ const firstPartial = !atBoundary(since), lastPartial = !atBoundary(until, true), retainedPartial = !atBoundary(retainedSince);
51
+ for (let date = first; date <= last; date += DAY) {
52
+ const key = new Date(date).toISOString().slice(0, 10);
53
+ daily.set(key, { date: key, calls: 0, measured: 0, shared: 0, providers: { claude: 0, codex: 0 }, durations: [],
54
+ status: key < retainedDay ? 'outside-history' : 'no-calls', partial: key === retainedDay && retainedPartial || key === firstDay && firstPartial || key === lastDay && lastPartial });
55
+ }
56
+ }
57
+ const groups = new Map(); let total = 0, measured = 0, failed = 0, pending = 0, orphaned = 0;
58
+ const details = [];
59
+ let totalMs = null, observedSince = null, observedUntil = null;
60
+ const providers = { claude: { calls: 0, measured: 0, failed: 0 }, codex: { calls: 0, measured: 0, failed: 0 } };
61
+ for (const [id, call] of calls) {
62
+ const start = call.start, end = call.end;
63
+ if (!start) { orphaned++; continue; }
64
+ if (start.t < since || start.t > until || query.provider !== 'all' && start.provider !== query.provider) continue;
65
+ total++; providers[start.provider].calls++;
66
+ observedSince = observedSince === null ? start.t : Math.min(observedSince, start.t);
67
+ observedUntil = observedUntil === null ? start.t : Math.max(observedUntil, start.t);
68
+ const duration = end && start.clock === end.clock && finite(start.at) && finite(end.at)
69
+ && end.at >= start.at && end.at - start.at <= 7 * DAY ? end.at - start.at : null;
70
+ if (duration !== null) { measured++; providers[start.provider].measured++; totalMs = (totalMs ?? 0) + duration; }
71
+ if (end?.failed) { failed++; providers[start.provider].failed++; }
72
+ if (!end) pending++;
73
+ const shared = start.operations.length > 1;
74
+ for (const operation of start.operations) {
75
+ let group = groups.get(operation);
76
+ if (!group) { group = { operation, calls: 0, completed: 0, failed: 0, missingTiming: 0, pending: 0, shared: 0, durations: [], providers: { claude: 0, codex: 0 }, tools: new Set() }; groups.set(operation, group); }
77
+ group.calls++; group.providers[start.provider]++; group.tools.add(start.tool);
78
+ if (end) group.completed++; else group.pending++;
79
+ if (end?.failed) group.failed++;
80
+ if (shared) group.shared++;
81
+ if (duration !== null) group.durations.push(duration); else group.missingTiming++;
82
+ if (operation === query.operation && daily.size) {
83
+ const bucket = daily.get(dayKey(start.t));
84
+ if (bucket) { bucket.calls++; bucket.providers[start.provider]++; if (shared) bucket.shared++; if (duration !== null) { bucket.measured++; bucket.durations.push(duration); } }
85
+ }
86
+ if (operation === query.operation) details.push({ id, operation, provider: start.provider, tool: start.tool,
87
+ capturedAt: start.t, durationMs: duration, state: !end ? 'unmatched' : end.failed ? 'failed' : 'success', clock: start.clock, shared });
88
+ }
89
+ }
90
+ const rows = [...groups.values()].map(g => {
91
+ const durations = g.durations.sort((a, b) => a - b);
92
+ const n = durations.length;
93
+ return { operation: g.operation, calls: g.calls, completed: g.completed, failed: g.failed,
94
+ errorRate: g.completed ? g.failed / g.completed : null, measured: n, missingTiming: g.missingTiming,
95
+ unmatched: g.pending, shared: g.shared, providers: g.providers, tools: [...g.tools],
96
+ totalMs: n ? durations.reduce((a, b) => a + b, 0) : null,
97
+ averageMs: n ? durations.reduce((a, b) => a + b, 0) / n : null,
98
+ medianMs: n ? n % 2 ? durations[Math.floor(n / 2)] : (durations[n / 2 - 1] + durations[n / 2]) / 2 : null,
99
+ p95Ms: n >= 20 ? percentile(durations, .95) : null, maxMs: n ? durations.at(-1) : null };
100
+ }).sort((a, b) => b.calls - a.calls || a.operation.localeCompare(b.operation));
101
+ return { ok: true, capturedAt: now, since, until, provider: query.provider, total, measured, failed, unmatched: pending,
102
+ corrupt, duplicates, orphaned, rows, totalMs, completed: total - pending, providers, observedSince, observedUntil,
103
+ retainedSince,
104
+ details: query.operation ? { operation: query.operation, total: details.length,
105
+ ...(query.history ? { capturedAt: now, measurements: rows.find(row => row.operation === query.operation) ?? null, history: { timeZone: query.timeZone ?? 'UTC', days: [...daily.values()].map(bucket => {
106
+ const sorted = bucket.durations.sort((a, b) => a - b), n = sorted.length;
107
+ return { date: bucket.date, calls: bucket.calls, measured: n, shared: bucket.shared, providers: bucket.providers, partial: bucket.partial,
108
+ status: n ? 'measured' : bucket.calls ? 'missing-timing' : bucket.status,
109
+ averageMs: n ? sorted.reduce((sum, duration) => sum + duration, 0) / n : null,
110
+ medianMs: n ? n % 2 ? sorted[Math.floor(n / 2)] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : null,
111
+ maxMs: n ? sorted.at(-1) : null, p95Ms: n >= 20 ? percentile(sorted, .95) : null };
112
+ }) } } : {}),
113
+ slowest: details.filter(d => d.durationMs !== null).sort((a, b) => b.durationMs - a.durationMs).slice(0, 20),
114
+ recent: details.sort((a, b) => b.capturedAt - a.capturedAt).slice(0, 30) } : null };
115
+ }
@@ -0,0 +1,38 @@
1
+ import { toolAudit } from './tool-audit.mjs';
2
+ import { readBody, send } from './http-io.mjs';
3
+ const calendarDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '') && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
4
+ export function auditQuery(params) {
5
+ const provider = params.get('provider') ?? 'all';
6
+ const days = Number(params.get('days') ?? 7);
7
+ const operation = params.get('operation') ?? undefined;
8
+ const since = params.get('since'), until = params.get('until');
9
+ const history = params.get('history');
10
+ let timeZone = params.get('timeZone') ?? 'UTC';
11
+ if (history !== null && history !== '1' || history && !operation || timeZone.length > 80) return null;
12
+ try { timeZone = new Intl.DateTimeFormat('en', { timeZone }).resolvedOptions().timeZone; } catch { return null; }
13
+ if (!['all', 'claude', 'codex'].includes(provider) || ![1, 7, 30, 60, 90].includes(days)
14
+ || operation && operation.length > 150 || !!since !== !!until) return null;
15
+ if (since && (!calendarDate(since) || !calendarDate(until) || since > until || Date.parse(until) - Date.parse(since) >= 90 * 86400000)) return null;
16
+ return { provider, days, operation, ...(history ? { history: true, timeZone } : {}), ...(since ? { since: Date.parse(since), until: Date.parse(until) + 86400000 - 1 } : {}) };
17
+ }
18
+ export async function handleToolAudit(req, res) {
19
+ const audit = toolAudit();
20
+ if (!audit) return send(res, 503, { ok: false, reason: 'unavailable' });
21
+ if (req.method === 'GET') {
22
+ if (new URL(req.url, 'http://localhost').pathname.endsWith('/storage')) return send(res, 200, await audit.status());
23
+ const query = auditQuery(new URL(req.url, 'http://localhost').searchParams);
24
+ if (!query) return send(res, 400, { ok: false, reason: 'bad_range' });
25
+ const controller = new AbortController();
26
+ const cancel = () => { if (!res.writableEnded) controller.abort(); };
27
+ res.once('close', cancel);
28
+ let report;
29
+ try { report = await audit.report(query, controller.signal); }
30
+ catch (e) { if (controller.signal.aborted) return; throw e; }
31
+ finally { res.off('close', cancel); }
32
+ return send(res, report.ok ? 200 : 409, report);
33
+ }
34
+ let body; try { body = JSON.parse(await readBody(req, res, 2048)); } catch { return send(res, 400, { ok: false, reason: 'bad_body' }); }
35
+ if (body?.action === 'clear' && body.confirm === true) return send(res, 200, await audit.clear());
36
+ if (body?.action === 'retention' && [30, 60, 90].includes(body.days)) return send(res, 200, await audit.configure(body.days));
37
+ return send(res, 400, { ok: false, reason: 'bad_action' });
38
+ }