agent-dag 1.46.3 → 1.48.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.
@@ -4009,6 +4009,141 @@ async function handleCodexQuota(req, res) {
4009
4009
  */
4010
4010
  export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
4011
4011
 
4012
+ /**
4013
+ * What a program drove in this machine's browsers while nobody was browsing.
4014
+ *
4015
+ * A GET, and deliberately not on the event stream. The answer costs a copy of a
4016
+ * History database that a browser holds locked, so it is pulled when the panel
4017
+ * is open and never on a timer — the same reasoning that keeps
4018
+ * /api/system/processes off the clock, for the same kind of cost.
4019
+ *
4020
+ * `refresh=1` drops the read cache. Without it a profile whose History has not
4021
+ * been written since the last look is answered from memory, which is the normal
4022
+ * case and the reason this route is cheap enough to poll while the panel is up.
4023
+ */
4024
+ /**
4025
+ * Turning the watch on or off, and how it is tuned.
4026
+ *
4027
+ * A POST, unlike its GET twin, because it writes to disk and because switching
4028
+ * the watch ON starts the deck keeping its own copy of what it sees — a record
4029
+ * of pages the user visited, which is not something a page they have open gets
4030
+ * to arrange for them. The router's own gate is what enforces that: every
4031
+ * non-GET goes through isTrustedMutation and isAuthorizedMutation before it
4032
+ * reaches a handler, which is exactly the pair a GET deliberately skips.
4033
+ */
4034
+ async function handleBrowserWatchSettings(req, res) {
4035
+ const raw = await readBody(req).catch(() => null);
4036
+ let body = null;
4037
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4038
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
4039
+
4040
+ const { readStore, writeStore, normalise } = await import(
4041
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4042
+ );
4043
+ const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
4044
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
4045
+ );
4046
+ const store = await readStore();
4047
+ // normalise() is the one place a value is judged, so a field this route has
4048
+ // never heard of cannot arrive through it and a bad one falls back rather
4049
+ // than reaching classify().
4050
+ const settings = normalise({ ...store.settings, ...body });
4051
+ // `dismissed` carried forward, and it has to be spelled: writeStore takes a
4052
+ // whole state and writes exactly what it is handed, so a caller that omits
4053
+ // this field ERASES it. Measured — changing the reaction wiped every
4054
+ // dismissal, so every episode the reader had reviewed came straight back on
4055
+ // the next poll, from a settings change that had nothing to do with them.
4056
+ await writeStore({ settings, episodes: store.episodes, dismissed: store.dismissed });
4057
+ // The one line in the log that is somebody acting rather than the deck
4058
+ // reading, which is exactly why it is worth its own entry.
4059
+ if (settings.enabled !== store.settings.enabled) {
4060
+ noteWatchSetting(settings.enabled ? "watch on — keeping its own copy" : "watch off — reading live only");
4061
+ } else {
4062
+ noteWatchSetting(`settings: quiet ${settings.quietMinutes}m, gap ${settings.gapMinutes}m`);
4063
+ }
4064
+ invalidateBrowserWatchCache();
4065
+ return send(res, 200, { ok: true, settings });
4066
+ }
4067
+
4068
+ /**
4069
+ * Mark one episode as reviewed, so it leaves the Findings list and stays gone.
4070
+ *
4071
+ * A DISMISSAL AND NOT A DELETION, and the difference is the whole design. The
4072
+ * panel rebuilds episodes from the browser's own history on every poll, so
4073
+ * removing the archived row would be undone within ten seconds by the next read
4074
+ * of the same visits. What is stored is that the reader has seen this one.
4075
+ *
4076
+ * The log file is untouched. It is the append-only record the panel promises —
4077
+ * "every address is written in full so you can check it yourself" — and a list
4078
+ * you can tidy is not the same thing as a record you can trust.
4079
+ */
4080
+ async function handleBrowserWatchDismiss(req, res) {
4081
+ // No gate of its own: the router runs isTrustedMutation and
4082
+ // isAuthorizedMutation on every non-GET before a handler sees it, which is
4083
+ // the pair its GET twin deliberately skips. A second check here would be a
4084
+ // second thing to keep correct.
4085
+ const raw = await readBody(req).catch(() => null);
4086
+ let body = null;
4087
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4088
+ const host = typeof body?.host === "string" ? body.host : null;
4089
+ const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
4090
+ if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
4091
+
4092
+ const { readStore, writeStore, episodeKey } = await import(
4093
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4094
+ );
4095
+ const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
4096
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
4097
+ );
4098
+ const store = await readStore();
4099
+ const key = episodeKey(host, startMs);
4100
+ const dismissed = [...new Set([...(store.dismissed ?? []), key])];
4101
+ await writeStore({ settings: store.settings, episodes: store.episodes, dismissed });
4102
+ // The reader acting on their own list, which is exactly the kind of line the
4103
+ // `act` level exists for.
4104
+ noteWatchSetting(`dismissed ${host}`);
4105
+ invalidateBrowserWatchCache();
4106
+ return send(res, 200, { ok: true });
4107
+ }
4108
+
4109
+ async function handleBrowserWatch(req, res) {
4110
+ const url = new URL(req.url, "http://localhost");
4111
+ const force = url.searchParams.get("refresh") === "1";
4112
+
4113
+ // Numbers from a query string are refused rather than coerced: NaN would
4114
+ // silently widen the quiet gate to "everything counts", which is the failure
4115
+ // mode that turns this panel into noise nobody reads.
4116
+ const minutes = name => {
4117
+ const raw = url.searchParams.get(name);
4118
+ if (raw === null) return undefined;
4119
+ const n = Number(raw);
4120
+ return Number.isFinite(n) && n > 0 && n <= 24 * 60 ? n * 60_000 : undefined;
4121
+ };
4122
+
4123
+ // Lazily, like every other handler here, and it earns it twice: the four
4124
+ // readers underneath reach for node:sqlite and copy files, and none of that
4125
+ // belongs on the path between `npx ccdeck` and a listening socket.
4126
+ //
4127
+ // Through fetchBrowserWatch rather than the snapshot directly, because
4128
+ // `refresh=1` drops the mtime cache and copies every profile's History
4129
+ // database. That module carries the floor and the inflight slot which bound
4130
+ // what a page looping this GET can spend.
4131
+ const { deckOwnOrigins, fetchBrowserWatch, registeredDeckPorts } = await import(
4132
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
4133
+ );
4134
+ // The registered ports as well as the documented range: a deck started with
4135
+ // an explicit `--port` outside 4317-4400 opens its own tab like any other,
4136
+ // and the panel used to report it to its owner as a program driving the
4137
+ // browser. Which it was — and the program was ccdeck.
4138
+ const ports = await registeredDeckPorts();
4139
+ return send(res, 200, await fetchBrowserWatch({
4140
+ force,
4141
+ deckOrigins: deckOwnOrigins(undefined, ports),
4142
+ quietMs: minutes("quiet"),
4143
+ gapMs: minutes("gap"),
4144
+ }));
4145
+ }
4146
+
4012
4147
  async function handleCcusage(req, res) {
4013
4148
  const url = new URL(req.url, "http://localhost");
4014
4149
  const force = url.searchParams.get("refresh") === "1";
@@ -4106,9 +4241,21 @@ async function handleClaudeAccountAdmin(req, res) {
4106
4241
  case "login": result = await admin.startLogin({ email: parsed.email }); break;
4107
4242
  case "login-code": result = await admin.submitLoginCode(parsed.code); break;
4108
4243
  case "login-cancel": result = await admin.cancelLogin(); break;
4109
- case "share": result = await admin.shareAccount(parsed.account); break;
4110
- case "import": result = await admin.importAccount(parsed.blob); break;
4244
+ // `accounts` is a chosen set, `account` the single row's own button. Both
4245
+ // go to shareAccounts, which treats one account as a bundle of one so the
4246
+ // two entry points cannot drift into two envelope shapes.
4247
+ case "share": result = await admin.shareAccounts(parsed.accounts ?? parsed.account); break;
4248
+ // `only` names one account inside the pasted bundle, and is the only way
4249
+ // `force` is honoured at all - see importAccount for why the pair is
4250
+ // required rather than the flag alone.
4251
+ case "import": result = await admin.importAccount(parsed.blob, {
4252
+ force: parsed.force === true,
4253
+ only: parsed.only ?? null,
4254
+ }); break;
4111
4255
  case "remove": result = await admin.removeAccount(parsed.account); break;
4256
+ // #721. Re-captures the active slot's credentials in place; see
4257
+ // recaptureActive for why this is not a login and cannot become one.
4258
+ case "recapture": result = await admin.recaptureActive(); break;
4112
4259
  case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
4113
4260
  case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
4114
4261
  default: return send(res, 400, { ok: false, reason: "unknown_action" });
@@ -4995,6 +5142,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
4995
5142
  }
4996
5143
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
4997
5144
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
5145
+ if (req.method === "GET" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatch(req, res), res);
5146
+ if (req.method === "POST" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatchSettings(req, res), res);
5147
+ if (req.method === "POST" && url.pathname === "/api/browser-watch/dismiss") return guard(handleBrowserWatchDismiss(req, res), res);
4998
5148
  if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
4999
5149
  if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
5000
5150
  if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
@@ -620,6 +620,19 @@ export async function writeDiscovery({ port, workspace, token, persist = null, c
620
620
  // than win it and record a rollout it is not even reading. See
621
621
  // writesCodexLog in src/server/log-writer.mjs.
622
622
  codex: codex !== false,
623
+ // Does this deck run Browser Watch? The watch elects a single writer among
624
+ // the decks on a machine, and it elected on port alone — so an older ccdeck
625
+ // that predates the feature won the election by having the lower port and
626
+ // then wrote nothing, while the deck that HAS the watch stood down. Measured
627
+ // on this machine: a v1.46 deck from an npx cache held 4317, answered the
628
+ // watch route with the SPA's index.html, and Browser Watch silently
629
+ // recorded nothing for as long as both were up. No error, no log line — the
630
+ // panel showed findings on screen and the disk stayed empty.
631
+ //
632
+ // Same shape as `codex` above, and for the same reason: a deck that is not
633
+ // doing the work must be left out of the election rather than win it. An
634
+ // older deck has no such field, so it is excluded by construction.
635
+ watch: true,
623
636
  startedAt: new Date().toISOString(),
624
637
  };
625
638
  await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");