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.
- package/README.md +6 -2
- package/dist/web/assets/index-DT1bdZn0.css +1 -0
- package/dist/web/assets/index-PPcCF-io.js +111 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/cswap-admin.mjs +419 -31
- package/src/server/index.mjs +152 -2
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/dist/web/assets/index-BQgkpz9j.css +0 -1
- package/dist/web/assets/index-CifRQYMu.js +0 -97
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
4110
|
-
|
|
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);
|
package/src/server/installer.mjs
CHANGED
|
@@ -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");
|