ccdeck 3.29.11 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// What the events log's append queue failed to write (#991), and how many lines
|
|
2
|
+
// have landed on each log in this process (#1130) — the two halves of the
|
|
3
|
+
// evidence event-log.mjs reads to say whether the log is being kept.
|
|
4
|
+
//
|
|
5
|
+
// Moved out of log-writer.mjs unchanged. appendLogLine reports every write
|
|
6
|
+
// that settles here, landed (noteAppendLanded) or not (noteAppendFailed);
|
|
7
|
+
// log-writer.mjs re-exports appendFailureStats and appendsLanded, which is
|
|
8
|
+
// where /api/health and event-log.mjs import them.
|
|
9
|
+
import { PRODUCT } from "./brand.mjs";
|
|
10
|
+
|
|
11
|
+
// ─── What the queue failed to write ───────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// `.catch(() => {})` was the only handler anywhere on this path, and a failed
|
|
14
|
+
// `open(filePath, "a")` was indistinguishable from a successful append from
|
|
15
|
+
// every vantage point in the process. MEASURED, on a sandboxed deck whose
|
|
16
|
+
// `--history` names a file inside a directory the user cannot write:
|
|
17
|
+
//
|
|
18
|
+
// POST /api/event acknowledged: 10 of 10
|
|
19
|
+
// log file on disk? false
|
|
20
|
+
// /api/health mentions the log? false
|
|
21
|
+
// /api/version canRestart: true
|
|
22
|
+
//
|
|
23
|
+
// Ten events the deck told the hook it had recorded, nothing on disk, nothing
|
|
24
|
+
// said on any console, and the Restart button still offering itself. That last
|
|
25
|
+
// line is the one that costs: the button exists precisely BECAUSE a restart
|
|
26
|
+
// without a log wipes the canvas irrecoverably, and it was gating on whether a
|
|
27
|
+
// log had been CONFIGURED rather than on whether one was being written. The
|
|
28
|
+
// press then lands on `replayLog`'s `if (!existsSync(filePath)) return 0` and
|
|
29
|
+
// the whole session history is gone.
|
|
30
|
+
//
|
|
31
|
+
// Who gets there without trying: `--history` accepts any path — a read-only or
|
|
32
|
+
// full volume, a removable drive unmounted while the deck runs, a directory
|
|
33
|
+
// whose permissions changed under it. `openEventLog` (event-log.mjs) even
|
|
34
|
+
// creates the parent inside a bare `try {} catch {}`, so the failure is already
|
|
35
|
+
// swallowed once before the first line is ever written.
|
|
36
|
+
//
|
|
37
|
+
// So the chain counts what it could not write, and says so once. The numbers
|
|
38
|
+
// are what make the loss observable without watching a canvas come back empty,
|
|
39
|
+
// for the same reason `eventBufferStats` exists in event-ring.mjs.
|
|
40
|
+
let failedLines = 0;
|
|
41
|
+
let failedChars = 0;
|
|
42
|
+
/**
|
|
43
|
+
* The paths currently inside an EPISODE of failing, and how many lines each
|
|
44
|
+
* episode has lost so far: path -> { lines }.
|
|
45
|
+
*
|
|
46
|
+
* An episode rather than a one-shot flag, because the interesting failures are
|
|
47
|
+
* transient — a volume that fills and is emptied, a drive that is unplugged and
|
|
48
|
+
* returns — and a path complained about once and never again would report the
|
|
49
|
+
* first outage of a long-lived deck and stay silent through every later one.
|
|
50
|
+
* The episode closes on the next append to that path that LANDS, which is the
|
|
51
|
+
* only evidence available that the condition is over.
|
|
52
|
+
*/
|
|
53
|
+
const failingPaths = new Map();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How many lines have LANDED on each path in this process: path -> count.
|
|
57
|
+
*
|
|
58
|
+
* The other half of the evidence `failingPaths` holds, and the half the restart
|
|
59
|
+
* gate was missing (#1130). An open episode says the newest append to a path
|
|
60
|
+
* failed. Nothing said the opposite — that one has succeeded SINCE some moment
|
|
61
|
+
* — because a path that never failed has no episode to close, and a log that
|
|
62
|
+
* could not be opened at boot and could be a minute later never failed an
|
|
63
|
+
* append at all: its first one simply landed. A count answers that by being
|
|
64
|
+
* compared with itself. event-log.mjs reads it once, when its boot probe
|
|
65
|
+
* answers, and again whenever it asks; any difference is a line that reached
|
|
66
|
+
* the disk after the probe spoke.
|
|
67
|
+
*
|
|
68
|
+
* One number per path this process has ever appended to, which on a deck is
|
|
69
|
+
* one. Never deleted, unlike the entries in log-writer.mjs's appendTails,
|
|
70
|
+
* because a count that went back to zero would read as "nothing has landed" to
|
|
71
|
+
* whoever took a reading before it did.
|
|
72
|
+
*/
|
|
73
|
+
const landedLines = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What the appender has failed to write, and whether it is failing now.
|
|
77
|
+
*
|
|
78
|
+
* Exported for the reason `eventBufferStats` and `MAX_BUFFER_CHARS` are: a loss
|
|
79
|
+
* whose only observable effect is a canvas that comes back empty after a
|
|
80
|
+
* restart is a loss no test can assert. `failing` is the number of paths inside
|
|
81
|
+
* an open failure episode — this process appends to exactly one log, so on a
|
|
82
|
+
* deck it is 0 or 1, and 1 means the log being drawn is not the log being kept.
|
|
83
|
+
*
|
|
84
|
+
* `failedLines` / `failedChars` are cumulative for the life of the process and
|
|
85
|
+
* count lines that were ATTEMPTED and did not land. A line the queue refused
|
|
86
|
+
* to accept in the first place is a different number and belongs beside this
|
|
87
|
+
* one rather than tangled into it.
|
|
88
|
+
*
|
|
89
|
+
* `filePath` NARROWS `failing` to one log, and a caller deciding what to do
|
|
90
|
+
* about its own log has to pass it. A process can outlive the log it was
|
|
91
|
+
* started with — `startServer` may be called again with a different `--history`
|
|
92
|
+
* on a restart, and the suite does exactly that — so an unscoped count would
|
|
93
|
+
* let a path that failed and was abandoned veto a deck whose current log is
|
|
94
|
+
* perfectly writable.
|
|
95
|
+
*
|
|
96
|
+
* @param {string|null} [filePath] the log to ask about; omit for every path
|
|
97
|
+
*/
|
|
98
|
+
export function appendFailureStats(filePath = null) {
|
|
99
|
+
const failing = filePath == null ? failingPaths.size : (failingPaths.has(filePath) ? 1 : 0);
|
|
100
|
+
return { failedLines, failedChars, failing };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How many lines have landed on one log in this process. See landedLines.
|
|
105
|
+
*
|
|
106
|
+
* A separate export rather than a fourth field of appendFailureStats, because
|
|
107
|
+
* that object is spread whole into `/api/health`, an open route that keeps to
|
|
108
|
+
* the smallest set of facts answering its question, and a running count of
|
|
109
|
+
* successful writes is not one of them.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} filePath the log to ask about
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
export function appendsLanded(filePath) {
|
|
115
|
+
return landedLines.get(filePath) ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One append landed. Counted whatever came before it — see landedLines — and
|
|
120
|
+
* if this path was failing, that is the end of the episode and the size of the
|
|
121
|
+
* hole it left is worth one line.
|
|
122
|
+
*/
|
|
123
|
+
export function noteAppendLanded(filePath) {
|
|
124
|
+
landedLines.set(filePath, (landedLines.get(filePath) ?? 0) + 1);
|
|
125
|
+
const episode = failingPaths.get(filePath);
|
|
126
|
+
if (!episode) return;
|
|
127
|
+
failingPaths.delete(filePath);
|
|
128
|
+
console.error(`${PRODUCT}: writing ${filePath} works again — ${episode.lines} event(s) were dropped while it did not, and are not in the log`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One append did not land. Count it, and open an episode if this is the first.
|
|
133
|
+
*
|
|
134
|
+
* ONE LINE PER EPISODE, not one per failure. A read-only log fails on every
|
|
135
|
+
* event the deck draws, so per-failure this would be thousands of lines onto
|
|
136
|
+
* the terminal the deck paints its own banner over — which is its own version
|
|
137
|
+
* of the problem being fixed.
|
|
138
|
+
*/
|
|
139
|
+
export function noteAppendFailed(filePath, line, err) {
|
|
140
|
+
failedLines++;
|
|
141
|
+
failedChars += typeof line === "string" ? line.length : 0;
|
|
142
|
+
const episode = failingPaths.get(filePath);
|
|
143
|
+
if (episode) { episode.lines++; return; }
|
|
144
|
+
failingPaths.set(filePath, { lines: 1 });
|
|
145
|
+
const why = err && err.message ? err.message : String(err);
|
|
146
|
+
console.error(`${PRODUCT}: cannot write the event log ${filePath} (${why}) — events are being drawn but not recorded, and a restart will not bring them back`);
|
|
147
|
+
}
|
package/src/server/args.mjs
CHANGED
|
@@ -56,6 +56,26 @@ export function isPortValue(raw) {
|
|
|
56
56
|
return n >= 0 && n <= 65535;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The port a start binds: `--port`, else AGENT_DAG_PORT, else 4317.
|
|
61
|
+
*
|
|
62
|
+
* An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
|
|
63
|
+
* not a request for port zero. `--port ""` never reaches this, because the
|
|
64
|
+
* parser records an empty value as `incomplete` and leaves the flag unset.
|
|
65
|
+
*
|
|
66
|
+
* Answers `{ port }`, or — for a value that is not a port — `refused`, naming
|
|
67
|
+
* which of the two it came from and what it said, so bin/deck.js can quote the
|
|
68
|
+
* user's own flag and value back at them.
|
|
69
|
+
*/
|
|
70
|
+
export function startPort({ flag, env } = {}) {
|
|
71
|
+
const envPort = env?.trim();
|
|
72
|
+
const rawPort = flag ?? (envPort ? envPort : null);
|
|
73
|
+
if (rawPort != null && !isPortValue(rawPort)) {
|
|
74
|
+
return { port: null, refused: { named: flag != null ? "--port" : "AGENT_DAG_PORT", raw: rawPort } };
|
|
75
|
+
}
|
|
76
|
+
return { port: rawPort == null ? 4317 : Number(rawPort), refused: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
/**
|
|
60
80
|
* Parse `process.argv.slice(2)`.
|
|
61
81
|
*
|
|
@@ -110,14 +130,26 @@ export function isPortValue(raw) {
|
|
|
110
130
|
* C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
|
|
111
131
|
* one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
|
|
112
132
|
*/
|
|
133
|
+
/**
|
|
134
|
+
* Whether a start serves one of the two CLIs: never with its `--no-` flag,
|
|
135
|
+
* always with its plain one, and otherwise when it looks installed. The same
|
|
136
|
+
* rule for Claude Code and for Codex, with the opt-out first so `--no-codex
|
|
137
|
+
* --codex` is a no. `installed` is a function because it looks at the disk,
|
|
138
|
+
* and a flag that already answered the question should not.
|
|
139
|
+
*/
|
|
140
|
+
export function wantsCli({ off, on, installed }) {
|
|
141
|
+
return off ? false : (on === true || installed());
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
/**
|
|
114
145
|
* The flags that do a thing and exit, rather than starting a deck.
|
|
115
146
|
*
|
|
116
|
-
* bin/agent-dag.js
|
|
117
|
-
* the whole reason it exists as a list
|
|
118
|
-
* at the call site: a one-shot that
|
|
119
|
-
* file and hand the terminal back
|
|
120
|
-
* the shape of the bug this
|
|
147
|
+
* bin/agent-dag.js asks this, through shouldDetach in detach.mjs, before it
|
|
148
|
+
* decides whether to detach, and that is the whole reason it exists as a list
|
|
149
|
+
* rather than as a condition written out at the call site: a one-shot that
|
|
150
|
+
* detached would print its answer into a log file and hand the terminal back
|
|
151
|
+
* empty. `ccdeck --version` detaching itself is the shape of the bug this
|
|
152
|
+
* prevents.
|
|
121
153
|
*/
|
|
122
154
|
export const ONE_SHOT = Object.freeze([
|
|
123
155
|
"help", "version", "uninstall", "stop", "status", "logs",
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Replacing a file that other processes read, in one step none of them can land
|
|
2
|
+
// inside — and reading it first, without mistaking a damaged file for an empty
|
|
3
|
+
// one.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/installer.mjs, between the hook entry matcher and
|
|
6
|
+
// the hook script installer. The installer is one caller among many: the
|
|
7
|
+
// retired sound hook, deck-prefs, the browser-watch store, the Codex token
|
|
8
|
+
// writer, uv-bootstrap, macmon, deck-home and the account-projects cache all
|
|
9
|
+
// reach for them too, and none of those installs a hook. So they moved to a
|
|
10
|
+
// leaf of their own, with the note on why each one is exported. Every one of
|
|
11
|
+
// those modules imports what it uses from here, as installer.mjs does: going
|
|
12
|
+
// through the installer loaded its whole module graph to reach a rename.
|
|
13
|
+
import { readFile, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
|
|
14
|
+
import { resolve, dirname } from "node:path";
|
|
15
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
16
|
+
import { PRODUCT } from "./brand.mjs";
|
|
17
|
+
|
|
18
|
+
// Notepad and PowerShell's Set-Content write UTF-8 with a byte-order mark, and
|
|
19
|
+
// JSON.parse throws on it when the file is read as utf8. A BOM is not damage —
|
|
20
|
+
// the JSON behind it is fine — so it never gets to look like a corrupt file.
|
|
21
|
+
function stripBom(text) {
|
|
22
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unreadableSettings(p, why) {
|
|
26
|
+
const err = new Error(
|
|
27
|
+
`${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
|
|
28
|
+
`fix the file or move it aside, then run ${PRODUCT} again.`,
|
|
29
|
+
);
|
|
30
|
+
err.code = "SETTINGS_UNREADABLE";
|
|
31
|
+
err.settingsPath = p;
|
|
32
|
+
// The bare reason, without the path and without the remedy sentence, so a
|
|
33
|
+
// caller that wants to phrase its own advice — `--uninstall` does; "run
|
|
34
|
+
// ccdeck again" is the wrong instruction there — does not have to take this
|
|
35
|
+
// message apart with a regex to get at the only part it cannot re-derive.
|
|
36
|
+
err.why = why;
|
|
37
|
+
return err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read settings we are about to rewrite. Only ENOENT means "nothing there yet";
|
|
42
|
+
* every other failure is a file whose contents we cannot reproduce — a stray
|
|
43
|
+
* comma, a half-written file from another process, a permission error — and
|
|
44
|
+
* writing our hooks over it would destroy every setting the user has. So the
|
|
45
|
+
* install refuses instead, loudly, and leaves the file exactly as it found it.
|
|
46
|
+
*/
|
|
47
|
+
async function readSettingsForWrite(p) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(p, "utf8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err?.code === "ENOENT") return { settings: {}, raw: null };
|
|
53
|
+
throw unreadableSettings(p, err?.message ?? String(err));
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(stripBom(raw));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw unreadableSettings(p, err?.message ?? String(err));
|
|
60
|
+
}
|
|
61
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
62
|
+
throw unreadableSettings(p, "top level is not a JSON object");
|
|
63
|
+
}
|
|
64
|
+
return { settings: parsed, raw };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rename over an open file is the one thing Windows does differently here.
|
|
68
|
+
// libuv's rename is one MoveFileExW with MOVEFILE_REPLACE_EXISTING and nothing
|
|
69
|
+
// else — no retry, no MOVEFILE_COPY_ALLOWED — and MoveFileEx honours share
|
|
70
|
+
// modes: it fails outright while ANY other handle is open on the source or the
|
|
71
|
+
// target without FILE_SHARE_DELETE. A virus scanner or the search indexer opens
|
|
72
|
+
// files the instant they are written, so the target is briefly untouchable on a
|
|
73
|
+
// perfectly healthy machine. Node has declined to paper over this (nodejs/node
|
|
74
|
+
// #29481, closed wontfix: "not something that's really under Node's or libuv's
|
|
75
|
+
// control"), and libuv reverted its own four-attempt ladder for the same
|
|
76
|
+
// reason. So the policy lives here, where the stakes are known.
|
|
77
|
+
//
|
|
78
|
+
// The ladder is 10 attempts over ~1.4s, and the second number is the one that
|
|
79
|
+
// matters. It used to be 5 over 200ms, which is comfortably enough for the
|
|
80
|
+
// indexer and not enough for a scanner: the argument on libuv#2098 for
|
|
81
|
+
// reverting their retry was in part that an AV hold can outlast 2s, and 200ms
|
|
82
|
+
// of patience on a hold like that is the same as none.
|
|
83
|
+
//
|
|
84
|
+
// What that thinness cost is not an install. codex-auth.mjs stages a REFRESH
|
|
85
|
+
// TOKEN through this call, the old one is spent server-side by the time it runs,
|
|
86
|
+
// and a rename that gives up too early destroys the only copy of the new
|
|
87
|
+
// credential — the deck reports refresh_rejected and the user has to run
|
|
88
|
+
// `codex login` again. Trading a second of latency against that is not close.
|
|
89
|
+
// The comparison points: steno retries a rename 10 times at 100ms, npm's
|
|
90
|
+
// bin-links 5 times at 500ms exponential, and write-file-atomic does not retry
|
|
91
|
+
// at all (npm/write-file-atomic#227).
|
|
92
|
+
//
|
|
93
|
+
// POSIX never hits this path, and a genuinely permanent permission error costs
|
|
94
|
+
// that second and a half once, on a path that was already failing.
|
|
95
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
96
|
+
|
|
97
|
+
async function renameWithRetry(from, to, attempts = 10) {
|
|
98
|
+
for (let attempt = 1; ; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
await rename(from, to);
|
|
101
|
+
return;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
|
|
104
|
+
// Linear: 30, 60, 90 … 270ms, ~1.4s across the nine waits. Linear rather
|
|
105
|
+
// than exponential because the holds this exists for are short and
|
|
106
|
+
// frequent, and a doubling ladder spends its whole budget on the last
|
|
107
|
+
// two waits.
|
|
108
|
+
await delay(30 * attempt);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Counts writes, not processes. The pid alone gave every call in one deck the
|
|
114
|
+
// same temp path, and there is more than one writer in a deck now: the sound
|
|
115
|
+
// toggle rewrites settings.json from a request handler, so two clients toggling
|
|
116
|
+
// within the same few milliseconds both opened that one path with O_TRUNC and
|
|
117
|
+
// both wrote their own JSON at offset zero. What got renamed over settings.json
|
|
118
|
+
// was the shorter payload with the tail of the longer one still behind it —
|
|
119
|
+
// unparseable, and readSettingsForWrite turns unparseable into a permanent
|
|
120
|
+
// SETTINGS_UNREADABLE refusal for every later toggle and install. The loser then
|
|
121
|
+
// found its temp file already renamed away and threw ENOENT on top of it.
|
|
122
|
+
let tmpSeq = 0;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create the temp file for one write, never sharing it with another writer.
|
|
126
|
+
*
|
|
127
|
+
* "wx" is the point of it: a name that already exists is an error here rather
|
|
128
|
+
* than a silently truncated file two writers are both filling in. The only way
|
|
129
|
+
* to meet a taken name is a deck killed between the write and the rename whose
|
|
130
|
+
* pid the OS has since handed out again — that writer is gone by definition, so
|
|
131
|
+
* the leftover is deleted and the next counter tried. Which is also the whole
|
|
132
|
+
* story on litter: the names come from a small space, this pid crossed with the
|
|
133
|
+
* first few counters, because a deck writes these files a handful of times per
|
|
134
|
+
* run. Later runs walk the same names and sweep what they find instead of
|
|
135
|
+
* piling fresh ones beside it. Digits and hyphens only — a legal filename
|
|
136
|
+
* everywhere, Windows included.
|
|
137
|
+
*
|
|
138
|
+
* `mode` is the mode the file is created with, which matters when the bytes are
|
|
139
|
+
* secret: codex-auth stages a rotated Codex refresh token here, and a temp file
|
|
140
|
+
* that starts at the umask default is readable by every other account on the
|
|
141
|
+
* box for as long as the write takes, whatever chmod follows it. O_EXCL is what
|
|
142
|
+
* makes the mode binding — a create honours it only when it is the call that
|
|
143
|
+
* makes the file, so adopting a leftover would keep the leftover's permissions.
|
|
144
|
+
*/
|
|
145
|
+
async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
146
|
+
for (let attempt = 1; ; attempt++) {
|
|
147
|
+
const tmp = `${target}.agent-dag-${process.pid}-${tmpSeq++}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
return { tmp, handle: await open(tmp, "wx", mode) };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (attempt >= attempts || err?.code !== "EEXIST") throw err;
|
|
152
|
+
await unlink(tmp).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A chain longer than this is a loop, or something no real setup has: stow and
|
|
158
|
+
// chezmoi produce one hop, an encrypted volume two. The number is a bound on the
|
|
159
|
+
// walk below, not a promise about how deep a legitimate link goes.
|
|
160
|
+
const MAX_LINK_HOPS = 8;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The file a name is really asking for — the one the links under it end at.
|
|
164
|
+
*
|
|
165
|
+
* A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
|
|
166
|
+
* deletes the link and leaves an ordinary file where it was, and
|
|
167
|
+
* `~/.claude/settings.json` is a symlink on a great many machines: into a
|
|
168
|
+
* dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
|
|
169
|
+
* survives — we write back what we read — so nothing looks wrong and nothing
|
|
170
|
+
* says anything. The repo copy keeps what it said before, never goes dirty, and
|
|
171
|
+
* from then on the user's edits there reach nobody while every launch rewrites
|
|
172
|
+
* the detached file and widens the gap. This is a file the deck did not create
|
|
173
|
+
* and does not own; quietly cutting it loose from the thing that manages it is
|
|
174
|
+
* worse than failing to write it at all.
|
|
175
|
+
*
|
|
176
|
+
* persistAuth in codex-auth.mjs has resolved for exactly this reason since it
|
|
177
|
+
* was written, on the file that is LESS often linked. This is the same rule at
|
|
178
|
+
* the helper every settings writer in the deck goes through — and the same
|
|
179
|
+
* function, so there is one of it rather than two that can drift.
|
|
180
|
+
*
|
|
181
|
+
* Resolving also decides which filesystem the temp file is staged on, and it has
|
|
182
|
+
* to be the target's. A rename is atomic within one filesystem and fails with
|
|
183
|
+
* EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
|
|
184
|
+
* a separate volume — is a write that cannot land at all.
|
|
185
|
+
*
|
|
186
|
+
* A DANGLING link is the case realpath alone cannot answer: the target not
|
|
187
|
+
* created yet, the encrypted volume not mounted. Answering it with the raw path
|
|
188
|
+
* is the bug again, because that is precisely when the link gets replaced — so
|
|
189
|
+
* the walk falls back to readlink, which reads a link without needing its target
|
|
190
|
+
* to exist, and follows it the way opening the name for writing would.
|
|
191
|
+
* `printf x > link` creates the target; it does not replace the link. If the
|
|
192
|
+
* target's directory is gone the write then fails, which is the honest answer:
|
|
193
|
+
* the bytes did not reach the file the user's setup points at.
|
|
194
|
+
*
|
|
195
|
+
* The ordinary case — a plain file, no link anywhere — is one realpath and the
|
|
196
|
+
* first return.
|
|
197
|
+
*/
|
|
198
|
+
async function resolveWriteTarget(raw) {
|
|
199
|
+
let at = raw;
|
|
200
|
+
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
|
|
201
|
+
// Resolves every link on the path at once, when all of them lead somewhere.
|
|
202
|
+
const real = await realpath(at).catch(() => null);
|
|
203
|
+
if (real !== null) return real;
|
|
204
|
+
const to = await readlink(at).catch(() => null);
|
|
205
|
+
// Not a link, so nothing exists at this name yet and this name is the
|
|
206
|
+
// answer: a first install, or the far end of a chain we have just followed.
|
|
207
|
+
if (to === null) return at;
|
|
208
|
+
at = resolve(dirname(at), to);
|
|
209
|
+
}
|
|
210
|
+
// Only a cycle gets here. Say so rather than pick a link out of it and
|
|
211
|
+
// destroy that one — the OS answers a write through such a name the same way.
|
|
212
|
+
const err = new Error(`too many symbolic links resolving ${raw}`);
|
|
213
|
+
err.code = "ELOOP";
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Replace a file in a single step readers cannot land inside.
|
|
219
|
+
*
|
|
220
|
+
* The temp file is created beside the target rather than in $TMPDIR, because
|
|
221
|
+
* rename is only atomic within one filesystem and the two are routinely on
|
|
222
|
+
* different ones. It is fsync'd before the rename so that a crash or power loss
|
|
223
|
+
* just after a successful install cannot leave the new directory entry pointing
|
|
224
|
+
* at blocks that were never flushed — the classic file-of-zero-bytes.
|
|
225
|
+
*
|
|
226
|
+
* "Beside the target" means beside the file the name resolves to, not beside the
|
|
227
|
+
* name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
|
|
228
|
+
* symlink.
|
|
229
|
+
*/
|
|
230
|
+
async function writeFileAtomic(rawTarget, text) {
|
|
231
|
+
const target = await resolveWriteTarget(rawTarget);
|
|
232
|
+
const { tmp, handle } = await createTemp(target);
|
|
233
|
+
try {
|
|
234
|
+
try {
|
|
235
|
+
await handle.writeFile(text, "utf8");
|
|
236
|
+
await handle.sync();
|
|
237
|
+
} finally {
|
|
238
|
+
await handle.close();
|
|
239
|
+
}
|
|
240
|
+
// A rename creates a fresh directory entry, so the old file's mode does not
|
|
241
|
+
// come with it. Carry it over — a settings.json the user chmod'ed to 600 has
|
|
242
|
+
// to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
|
|
243
|
+
const mode = await stat(target).then(s => s.mode, () => null);
|
|
244
|
+
if (mode !== null) await chmod(tmp, mode).catch(() => {});
|
|
245
|
+
// A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
|
|
246
|
+
// rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
|
|
247
|
+
// replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
|
|
248
|
+
// over a 0444 file succeeds, because only the parent directory's write bit
|
|
249
|
+
// decides. A settings.json picks that attribute up from a OneDrive restore,
|
|
250
|
+
// a copy off a network share, or read-only media — and EACCES is in the
|
|
251
|
+
// retry ladder, so the whole ~1.4s was spent before throwing, on every
|
|
252
|
+
// boot, forever. Every settings writer goes through here, so hooks never
|
|
253
|
+
// installed and the sound-hook retirement could never repair a stale entry
|
|
254
|
+
// either.
|
|
255
|
+
//
|
|
256
|
+
// chmod on Windows toggles exactly that attribute and nothing else, which
|
|
257
|
+
// is why this is safe to do unconditionally there: the mode carried above
|
|
258
|
+
// is re-applied to the new file after the rename, so a file the user marked
|
|
259
|
+
// read-only stays read-only.
|
|
260
|
+
// ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
|
|
261
|
+
// path between the temp write and the rename are not free on Windows:
|
|
262
|
+
// discovery-live.test.ts hammers writeDiscovery while a reader holds the
|
|
263
|
+
// destination open, and the wider window turned a rename the retry ladder
|
|
264
|
+
// used to win into an EPERM it gave up on. The attribute is what this
|
|
265
|
+
// clears, so a file that does not carry it has nothing to clear.
|
|
266
|
+
const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
|
|
267
|
+
if (readOnly) await chmod(target, 0o666).catch(() => {});
|
|
268
|
+
await renameWithRetry(tmp, target);
|
|
269
|
+
if (readOnly) await chmod(target, mode).catch(() => {});
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// Cleanup covers the write and the fsync as well as the rename: a full disk
|
|
272
|
+
// used to leave the half-written temp file sitting beside the target.
|
|
273
|
+
await unlink(tmp).catch(() => {});
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Exported for the other modules that rewrite settings.json — the sound toggle
|
|
279
|
+
// today. Every one of them needs the same two guarantees: a file we cannot
|
|
280
|
+
// parse is never treated as an empty one, and the replacement is a single
|
|
281
|
+
// rename rather than a truncate a reader can land inside. installScript carries
|
|
282
|
+
// the same guarantee to the hook scripts themselves, which are the files live
|
|
283
|
+
// sessions are actually executing, and renameWithRetry goes out on its own for
|
|
284
|
+
// the files writeFileAtomic cannot write — the fetched uv binary in
|
|
285
|
+
// uv-bootstrap.mjs — which still need the Windows retry. createTemp goes out for
|
|
286
|
+
// the same reason one step lower: codex-auth.mjs needs the collision-free temp
|
|
287
|
+
// name but not writeFileAtomic's mode handling, which carries over the target's
|
|
288
|
+
// mode and so would leave a brand-new auth.json at whatever the umask allows.
|
|
289
|
+
// resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
|
|
290
|
+
// repo for the same reasons settings.json is, and "never rename onto a link" is
|
|
291
|
+
// one rule: codex-auth.mjs called a realpath of its own before this existed, and
|
|
292
|
+
// two spellings of a rule are two things that can drift.
|
|
293
|
+
// `stripBom` goes out on the same argument at the smallest scale. Two stores
|
|
294
|
+
// now move an unparseable file aside instead of writing defaults over it —
|
|
295
|
+
// deck-prefs.mjs since #1002 and browser-watch-store.mjs since #1003 — and all
|
|
296
|
+
// three readers have to mean the same thing by "a BOM is not damage". If they
|
|
297
|
+
// do not, a prefs.json or a state.json somebody opened in Notepad gets
|
|
298
|
+
// quarantined for a mark settings.json has ignored since this function was
|
|
299
|
+
// written.
|
|
300
|
+
export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget, stripBom };
|
|
@@ -36,11 +36,6 @@
|
|
|
36
36
|
// the second caller rather than a second implementation.
|
|
37
37
|
import { basename } from "node:path";
|
|
38
38
|
|
|
39
|
-
/** Set `AGENTS_DECK_NO_NOTIFY=1` to keep the deck off the desktop entirely.
|
|
40
|
-
* Same shape as AGENTS_DECK_NO_DOWNLOAD and AGENTS_DECK_NO_INSTALL, which is
|
|
41
|
-
* the sheet of switches a user already knows to look for. */
|
|
42
|
-
export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
|
|
43
|
-
|
|
44
39
|
/**
|
|
45
40
|
* How long one session stays quiet after it has been announced.
|
|
46
41
|
*
|
package/src/server/boot-lock.mjs
CHANGED
|
@@ -115,7 +115,7 @@ export async function takeBootLock({
|
|
|
115
115
|
const file = join(dir, BOOT_LOCK_FILE);
|
|
116
116
|
const nonce = randomBytes(8).toString("hex");
|
|
117
117
|
let waited = false;
|
|
118
|
-
// 0700 like the registry beside it —
|
|
118
|
+
// 0700 like the registry beside it — discovery.mjs asserts the same mode on
|
|
119
119
|
// the same directory, and whichever runs first creates it.
|
|
120
120
|
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* the open below decides */ }
|
|
121
121
|
for (;;) {
|
package/src/server/brand.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The name the server prints, in one place — the console half of what
|
|
2
|
-
// src/web/brand.ts holds for the browser and src/server/
|
|
3
|
-
// banner wordmark. Every `PRODUCT:` prefix below is a line a user reads when
|
|
2
|
+
// src/web/brand.ts holds for the browser and src/server/wordmark.mjs draws as
|
|
3
|
+
// the banner wordmark. Every `PRODUCT:` prefix below is a line a user reads when
|
|
4
4
|
// something has already gone wrong, which is the worst moment to introduce
|
|
5
5
|
// yourself by a name that appears nowhere else they have seen.
|
|
6
6
|
//
|
|
7
|
-
// Display name only. The package published to npm is
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// Display name only. The package published to npm is a separate fact, even
|
|
8
|
+
// though it has been spelled `ccdeck` too since the old names were retired:
|
|
9
|
+
// install-layout.mjs's PUBLISHED_NAME is what the registry is queried for and
|
|
10
|
+
// what `npm i -g` installs, and an install that names the product instead of
|
|
11
|
+
// the package installs nothing once the two differ again. Same for the
|
|
11
12
|
// marker files under ~/.agents-deck, the event log under ~/.claude/agent-dag,
|
|
12
13
|
// the AGENTS_DECK_* variables and the `user-agent` headers: identifiers outlive
|
|
13
14
|
// a rename, and moving one orphans a running deck from its own state.
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// The session that opened it is still attached and can still read every other
|
|
21
21
|
// tab. Only quitting takes anything back.
|
|
22
22
|
import { run } from "./exec.mjs";
|
|
23
|
+
import { notificationsVetoed } from "./deck-prefs.mjs";
|
|
23
24
|
// The one table of process names, shared with the presence probe so the reaction
|
|
24
25
|
// and the "is it running" answer can never disagree about what to look for —
|
|
25
26
|
// and, with it, the two questions that table cannot answer on its own: which
|
|
@@ -405,11 +406,18 @@ async function closeByImage(proc, exec, reason) {
|
|
|
405
406
|
* Always notifies, whatever else it does. A tab that closed itself with no
|
|
406
407
|
* explanation is a mystery rather than a warning, and the point of the feature
|
|
407
408
|
* is that the user finds out.
|
|
409
|
+
*
|
|
410
|
+
* Unless the deck was launched with AGENTS_DECK_NO_NOTIFY=1, which keeps it
|
|
411
|
+
* off the desktop entirely (#1677). The finding is still in the panel and the
|
|
412
|
+
* log, and the feed line says why no notification came, so a reaction that
|
|
413
|
+
* closed a tab is still explained where the reader looks for it.
|
|
408
414
|
*/
|
|
409
|
-
export async function react(reaction, episode, { platform = process.platform, deps = {} } = {}) {
|
|
415
|
+
export async function react(reaction, episode, { platform = process.platform, deps = {}, env = process.env } = {}) {
|
|
410
416
|
const done = [];
|
|
411
417
|
const pages = `${episode.count} page${episode.count === 1 ? "" : "s"}`;
|
|
412
|
-
if (
|
|
418
|
+
if (notificationsVetoed(env)) {
|
|
419
|
+
done.push("not notified — AGENTS_DECK_NO_NOTIFY=1");
|
|
420
|
+
} else if (await notify("Browser watch", `${episode.host} — ${pages} while you were away`, platform, deps)) {
|
|
413
421
|
done.push("notified");
|
|
414
422
|
} else {
|
|
415
423
|
done.push("could not notify");
|