ccdeck 1.43.0 → 1.45.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 +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// When a quota window resets, written one way — for the Claude lanes and the
|
|
2
|
+
// Codex lanes both, which land in the same panel.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS MODULE EXISTS (#374). quota.mjs had `fmtResetIso` and codex-quota.mjs
|
|
5
|
+
// had `fmtReset`. They passed identical option bags to `toLocaleString` and then
|
|
6
|
+
// post-processed the result differently, and codex-quota's own doc comment said
|
|
7
|
+
// of its copy: "matches the Claude quota formatting so both read alike." It did
|
|
8
|
+
// not. Over the same instant:
|
|
9
|
+
//
|
|
10
|
+
// toLocaleString : "Jun 18, 4:09 PM"
|
|
11
|
+
// quota.mjs : "Jun 18, 4:09pm"
|
|
12
|
+
// codex-quota.mjs: "jun 18 4:09pm" ← comma stripped, month lower-cased
|
|
13
|
+
//
|
|
14
|
+
// Swept over 2,794 instants across 40 days, the two disagreed on every single
|
|
15
|
+
// one — not at a boundary, on all of them, because the difference is two
|
|
16
|
+
// unconditional string operations rather than a tier that rarely fires. Both
|
|
17
|
+
// strings surface in the same usage panel, one lane above the other.
|
|
18
|
+
//
|
|
19
|
+
// The Claude rendering is the one kept, on the comment's own terms: it is the
|
|
20
|
+
// one the other claimed to match, and it is the one that reads like a date —
|
|
21
|
+
// `Intl` capitalises the month and puts the comma in for the locale's own
|
|
22
|
+
// reasons, and stripping both is a hand edit to output that was already right.
|
|
23
|
+
// This is a FIX to what the Codex lanes print, not a neutral merge, and it is
|
|
24
|
+
// the only visible change in this consolidation.
|
|
25
|
+
//
|
|
26
|
+
// The one genuine difference between the copies was the input unit — Codex
|
|
27
|
+
// answers with a Unix timestamp in seconds and Anthropic with an ISO-8601
|
|
28
|
+
// string — so that is the wrapper below rather than a second formatter.
|
|
29
|
+
//
|
|
30
|
+
// A module of its own rather than one importing the other: quota.mjs reads
|
|
31
|
+
// Claude's OAuth credentials off disk, and having the Codex path import that to
|
|
32
|
+
// reach a date formatter would drag the whole Claude credential chain into a
|
|
33
|
+
// request that has nothing to do with it.
|
|
34
|
+
|
|
35
|
+
/** en-US on purpose, not the host locale. This string is generated on the
|
|
36
|
+
* server and shipped to a browser whose locale nobody here has asked, so a
|
|
37
|
+
* server set to de-DE would otherwise send "18. Juni, 16:09" into a panel
|
|
38
|
+
* written in English. Both copies already hardcoded en-US; it is written down
|
|
39
|
+
* here so the next reader knows it was a decision. */
|
|
40
|
+
const OPTS = { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true };
|
|
41
|
+
|
|
42
|
+
/** The rendering itself, over a Date both entry points have already validated.
|
|
43
|
+
* The meridiem is lower-cased and its space removed — the only edit made to
|
|
44
|
+
* what `Intl` produces — because "4:09 PM" shouts in a lane label that is a
|
|
45
|
+
* few characters of chrome wide. */
|
|
46
|
+
function label(d) {
|
|
47
|
+
return d.toLocaleString("en-US", OPTS).replace(/\s+(AM|PM)/, (_, p) => p.toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "Jun 18, 4:09pm" from a Unix timestamp in seconds, which is how the Codex
|
|
51
|
+
* usage endpoint spells a reset time. Null when there is no reset to name.
|
|
52
|
+
*
|
|
53
|
+
* The validity check is inherited from the ISO copy, which had one where the
|
|
54
|
+
* seconds copy did not: a finite but absurd number — the only shape that gets
|
|
55
|
+
* past the falsy guard and still fails to be a date — used to render as the
|
|
56
|
+
* literal string "invalid date" in a quota lane. Nothing observed sends one,
|
|
57
|
+
* so this is hardening rather than a fix, and it is the safer of the two
|
|
58
|
+
* behaviours in the same way `isOlder`'s type guard is. */
|
|
59
|
+
export function resetLabel(unixSec) {
|
|
60
|
+
if (!unixSec) return null;
|
|
61
|
+
const d = new Date(unixSec * 1000);
|
|
62
|
+
if (isNaN(d.getTime())) return null;
|
|
63
|
+
return label(d);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The same label from an ISO-8601 instant, which is how Anthropic's usage
|
|
67
|
+
* endpoint spells a reset time. Invalid and absent both answer null, so a
|
|
68
|
+
* malformed field renders as no reset rather than "Invalid Date".
|
|
69
|
+
*
|
|
70
|
+
* Not written as `resetLabel(d.getTime() / 1000)`: that would send an instant
|
|
71
|
+
* at the Unix epoch through the falsy guard above and answer null for a date
|
|
72
|
+
* this function has already established is valid. */
|
|
73
|
+
export function resetLabelIso(iso) {
|
|
74
|
+
if (!iso) return null;
|
|
75
|
+
const d = new Date(iso);
|
|
76
|
+
if (isNaN(d.getTime())) return null;
|
|
77
|
+
return label(d);
|
|
78
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Takes the deck's old finish-sound hook back off a machine that already has it.
|
|
2
|
+
//
|
|
3
|
+
// Until #704 the deck played its "turn finished" sound by writing a `Stop` entry
|
|
4
|
+
// into the user's settings.json whose command ran `notify.mjs` out of the deck's
|
|
5
|
+
// own install directory. The deck plays that sound itself now, from the `Stop`
|
|
6
|
+
// and `Notification` envelopes it already receives, and the script is gone from
|
|
7
|
+
// the package — so an entry naming it is a hook pointing at a file that does not
|
|
8
|
+
// exist, and Claude Code runs it at the end of every turn. On a machine that was
|
|
9
|
+
// working yesterday. That is what this module exists to prevent, and it is the
|
|
10
|
+
// whole of what is left here: there is no installer, no toggle, no status
|
|
11
|
+
// reporter and no re-assert. Only the removal, and the promise the removal owes.
|
|
12
|
+
//
|
|
13
|
+
// THE PROMISE. Turning the sound on used to PARK any sound hook the user had
|
|
14
|
+
// written themselves — an `afplay` line, a PowerShell one — in
|
|
15
|
+
// ~/.agents-deck/parked-sound-hooks.json, so that "off" produced actual silence
|
|
16
|
+
// and "on" did not play twice. Those hooks are the user's, the deck is the only
|
|
17
|
+
// thing that knows where they went, and this is the last code that will ever be
|
|
18
|
+
// in a position to hand them back. So retirement is not "delete our entry": it
|
|
19
|
+
// is "delete our entry and put theirs back where they wrote it".
|
|
20
|
+
//
|
|
21
|
+
// WHAT COUNTS AS OURS. Two rules, and they cover different machines.
|
|
22
|
+
//
|
|
23
|
+
// The `__agent-dag-sound` mark is what this deck wrote, and it survives a
|
|
24
|
+
// settings.json synced from another computer — where every path in the command
|
|
25
|
+
// belongs to that computer and matches nothing here.
|
|
26
|
+
//
|
|
27
|
+
// An entry whose command names one of our installed scripts is ours too, mark
|
|
28
|
+
// or no mark. <claude config dir>/agent-dag/ is a directory this deck creates
|
|
29
|
+
// and fills; nobody hand-writes a Stop hook that runs `node
|
|
30
|
+
// ~/.claude/agent-dag/notify.mjs`. The author's own machine is the case: two
|
|
31
|
+
// Stop entries naming the installed notify.js with the mark missing from both,
|
|
32
|
+
// which the mark rule alone would have left behind — playing a sound with no
|
|
33
|
+
// switch anywhere that could stop it, or crashing once the script was swept.
|
|
34
|
+
//
|
|
35
|
+
// Everything else in the file is the user's and is not touched. `afplay …` stays
|
|
36
|
+
// exactly where they put it, and this module has no idea what it does.
|
|
37
|
+
//
|
|
38
|
+
// EXACTLY ONCE, without a stamp. Retirement is triggered by the state it
|
|
39
|
+
// removes: our entry in settings.json, a parked file, or one of our scripts on
|
|
40
|
+
// disk. When it has run there is none of that left, so the next boot asks three
|
|
41
|
+
// `existsSync` questions, gets three noes, and writes nothing — the same answer
|
|
42
|
+
// every boot after it, forever. A "retirement done" marker file would have been
|
|
43
|
+
// the other spelling and is the wrong one: a marker can say done about a machine
|
|
44
|
+
// whose settings.json was later restored from a backup carrying the old entry,
|
|
45
|
+
// and then the broken hook lives there permanently. State that describes itself
|
|
46
|
+
// cannot drift from itself.
|
|
47
|
+
//
|
|
48
|
+
// A DECK THAT CANNOT WRITE. Nothing here is done speculatively and nothing is
|
|
49
|
+
// recorded as done that was not. A settings.json that will not parse stops
|
|
50
|
+
// retirement with the file byte for byte as it was found (see
|
|
51
|
+
// readSettingsForWrite: this rewrites the whole file, so treating a damaged one
|
|
52
|
+
// as `{}` would replace every permission, env var and hook in it with nothing).
|
|
53
|
+
// An unwritable one throws out of the write and the boot reports it. A parked
|
|
54
|
+
// file that will not read leaves the park alone and still removes our entry,
|
|
55
|
+
// because those are two independent repairs and only one of them is urgent.
|
|
56
|
+
// In every one of those cases the trigger state is still on disk, so the next
|
|
57
|
+
// boot tries again. There is nothing to reset.
|
|
58
|
+
//
|
|
59
|
+
// TWO DECKS BOOTING AT ONCE. Both read the same settings, both compute the same
|
|
60
|
+
// result, and both write it through writeFileAtomic — a rename, so the file is
|
|
61
|
+
// one whole payload whichever lands second. The park is the part that could
|
|
62
|
+
// have gone wrong: deck B reading the park before A deleted it and settings
|
|
63
|
+
// after A wrote it would put the user's hooks back a second time, on top of the
|
|
64
|
+
// copy A had just restored. So a parked entry is spliced back only when an
|
|
65
|
+
// identical one is not already in the group. Restoring is idempotent, and the
|
|
66
|
+
// race stops being one.
|
|
67
|
+
import { readFile, rm } from "node:fs/promises";
|
|
68
|
+
import { existsSync } from "node:fs";
|
|
69
|
+
import { join } from "node:path";
|
|
70
|
+
import { homedir } from "node:os";
|
|
71
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
72
|
+
import { readSettingsForWrite, writeFileAtomic } from "./installer.mjs";
|
|
73
|
+
|
|
74
|
+
const CLAUDE_DIR = claudeConfigDir();
|
|
75
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
|
|
76
|
+
const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
|
|
77
|
+
|
|
78
|
+
// Both names the sound script ever had. `.js` is the pre-#577 spelling, which
|
|
79
|
+
// was CommonJS-by-default in a directory with no package.json above it and so a
|
|
80
|
+
// `SyntaxError: Cannot use import statement` at the end of every turn on the
|
|
81
|
+
// older half of this package's `engines` range; `.mjs` is what replaced it.
|
|
82
|
+
// Retirement has to know both, because a machine that never upgraded past #577
|
|
83
|
+
// is exactly the kind of machine this runs on.
|
|
84
|
+
const NOTIFY_PATH = join(INSTALL_DIR, "notify.mjs");
|
|
85
|
+
const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
|
|
86
|
+
const OUR_SCRIPTS = [NOTIFY_PATH, LEGACY_NOTIFY_PATH];
|
|
87
|
+
|
|
88
|
+
const MARK = "__agent-dag-sound";
|
|
89
|
+
const EVENT = "Stop";
|
|
90
|
+
// Where the user's own sound hooks were put while the toggle was on. Nothing
|
|
91
|
+
// writes this any more; retirement reads it once and deletes it.
|
|
92
|
+
const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
93
|
+
|
|
94
|
+
const commandsOf = (entry) =>
|
|
95
|
+
(entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
|
|
96
|
+
|
|
97
|
+
/** An entry this deck put there: by its mark, or by the script it runs. */
|
|
98
|
+
function isOurs(entry) {
|
|
99
|
+
if (entry?.[MARK] === true) return true;
|
|
100
|
+
return commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
|
|
104
|
+
* The sweep below asks this before deleting them: a stale sound is survivable
|
|
105
|
+
* and a hook pointing at nothing is not. */
|
|
106
|
+
function anythingStillNamesOurScripts(settings) {
|
|
107
|
+
const groups = settings?.hooks;
|
|
108
|
+
if (!groups || typeof groups !== "object") return false;
|
|
109
|
+
for (const group of Object.values(groups)) {
|
|
110
|
+
if (!Array.isArray(group)) continue;
|
|
111
|
+
for (const entry of group) {
|
|
112
|
+
if (commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)))) return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parkedError(why) {
|
|
119
|
+
const err = new Error(
|
|
120
|
+
`${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, so it ` +
|
|
121
|
+
`is not being treated as empty and it has not been deleted — repair it or move it aside, and the ` +
|
|
122
|
+
`deck will hand them back on its next start.`,
|
|
123
|
+
);
|
|
124
|
+
err.code = "PARKED_UNREADABLE";
|
|
125
|
+
err.parkedPath = PARKED_PATH;
|
|
126
|
+
return err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The hooks the toggle set aside, or a refusal.
|
|
131
|
+
*
|
|
132
|
+
* Only ENOENT is genuinely empty. A truncated file — a kill mid-write, a full
|
|
133
|
+
* disk — used to read as "nothing was ever parked", and this is the only copy of
|
|
134
|
+
* hooks a user wrote by hand: answering "restored: 0" about a file with their
|
|
135
|
+
* work in it, and then deleting it, is the one unrecoverable thing in this
|
|
136
|
+
* module. A JSON object rather than an array is a file that is not ours.
|
|
137
|
+
*/
|
|
138
|
+
async function readParked() {
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = await readFile(PARKED_PATH, "utf8");
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err?.code === "ENOENT") return [];
|
|
144
|
+
throw parkedError(err?.message ?? String(err));
|
|
145
|
+
}
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(raw);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw parkedError(err?.message ?? String(err));
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(parsed)) throw parkedError("top level is not a JSON array");
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Is there anything of the retired mechanism left on this machine? Three
|
|
157
|
+
* `existsSync` calls and a scan of a settings object the caller already read —
|
|
158
|
+
* which is the whole cost of retirement on every boot after the first. */
|
|
159
|
+
function anythingToRetire(settings) {
|
|
160
|
+
const group = settings?.hooks?.[EVENT];
|
|
161
|
+
if (Array.isArray(group) && group.some(isOurs)) return true;
|
|
162
|
+
if (existsSync(PARKED_PATH)) return true;
|
|
163
|
+
return OUR_SCRIPTS.some(existsSync);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Nothing here, and nothing for the caller to do afterwards. */
|
|
167
|
+
const NOTHING = Object.freeze({ pending: false, changed: false, removed: 0, restored: 0, parkError: null });
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Retire the sound hook inside a settings object the caller is about to write.
|
|
171
|
+
*
|
|
172
|
+
* Mutates `settings` and returns what it did; the caller owns the write, so a
|
|
173
|
+
* boot that would otherwise change nothing still changes nothing. Call
|
|
174
|
+
* `completeSoundHookRetirement` with the result AFTER settings.json is on disk —
|
|
175
|
+
* that ordering is the point of the split. Until the new file has landed, the
|
|
176
|
+
* old command is still what a live Claude Code session will run at the end of
|
|
177
|
+
* its next turn, and deleting the script it names turns a stale sound into a
|
|
178
|
+
* "Cannot find module" in the user's session.
|
|
179
|
+
*
|
|
180
|
+
* Never throws over the parked file. A corrupt ~/.agents-deck must not stop the
|
|
181
|
+
* deck from booting, and it must not stop the urgent half either: our entry
|
|
182
|
+
* points at a script that is about to be deleted, and taking it out is worth
|
|
183
|
+
* doing whether or not the user's own hooks can be handed back in the same pass.
|
|
184
|
+
*/
|
|
185
|
+
export async function retireSoundHookIn(settings) {
|
|
186
|
+
if (!anythingToRetire(settings)) return NOTHING;
|
|
187
|
+
|
|
188
|
+
let parked = [];
|
|
189
|
+
let parkError = null;
|
|
190
|
+
try {
|
|
191
|
+
parked = await readParked();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err?.code !== "PARKED_UNREADABLE") throw err;
|
|
194
|
+
parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
|
|
198
|
+
const theirs = group.filter(g => !isOurs(g));
|
|
199
|
+
const removed = group.length - theirs.length;
|
|
200
|
+
|
|
201
|
+
// Identical entries are not restored twice — see the note on two decks at the
|
|
202
|
+
// top. `theirs` is what will be in the file, so a hook already back from an
|
|
203
|
+
// earlier attempt (or from the other deck, a millisecond ago) is recognised.
|
|
204
|
+
//
|
|
205
|
+
// And an entry that is OURS is never restored, wherever it was found. The park
|
|
206
|
+
// is not supposed to contain one — the old toggle set aside hooks that looked
|
|
207
|
+
// hand-written and skipped its own — but "supposed to" is doing all the work
|
|
208
|
+
// in that sentence: the file is years old on some machines, it is synced
|
|
209
|
+
// between them, and the unmarked entries naming our installed script are
|
|
210
|
+
// exactly the shape a hand-written-hook filter would have swept up. Restoring
|
|
211
|
+
// one would put back the hook this whole module exists to remove, pointing at
|
|
212
|
+
// a script this release deletes, on the boot that was supposed to repair it.
|
|
213
|
+
const seen = new Set(theirs.map(g => JSON.stringify(g)));
|
|
214
|
+
const putBack = [];
|
|
215
|
+
for (const entry of parked) {
|
|
216
|
+
if (isOurs(entry)) continue;
|
|
217
|
+
const key = JSON.stringify(entry);
|
|
218
|
+
if (seen.has(key)) continue;
|
|
219
|
+
seen.add(key);
|
|
220
|
+
putBack.push(entry);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const next = [...putBack, ...theirs];
|
|
224
|
+
let changed = false;
|
|
225
|
+
if (removed > 0 || putBack.length > 0) {
|
|
226
|
+
changed = true;
|
|
227
|
+
settings.hooks ??= {};
|
|
228
|
+
if (next.length) settings.hooks[EVENT] = next;
|
|
229
|
+
else delete settings.hooks[EVENT]; // don't leave an empty array behind
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
pending: true,
|
|
234
|
+
changed,
|
|
235
|
+
removed,
|
|
236
|
+
restored: putBack.length,
|
|
237
|
+
// Only when the whole park was accounted for. A read that refused leaves the
|
|
238
|
+
// file for repair, and the next boot tries again.
|
|
239
|
+
clearPark: parkError === null && existsSync(PARKED_PATH),
|
|
240
|
+
parkError,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The half of retirement that must happen after settings.json is on disk.
|
|
246
|
+
*
|
|
247
|
+
* Deleting the parked file is safe here and only here: its contents are in the
|
|
248
|
+
* file Claude Code reads. If the delete fails — a read-only ~/.agents-deck, a
|
|
249
|
+
* Windows lock — the next boot reads the same park and restores nothing, because
|
|
250
|
+
* the hooks it names are already in the group. That is the whole reason the
|
|
251
|
+
* restore de-duplicates.
|
|
252
|
+
*
|
|
253
|
+
* The scripts go last, and only when nothing in settings.json still names them.
|
|
254
|
+
* Retirement removes every entry that does, so the guard is normally already
|
|
255
|
+
* satisfied; it exists for the file that puts one under some other event, where
|
|
256
|
+
* leaving a stale sound is right and leaving a missing module is not.
|
|
257
|
+
*/
|
|
258
|
+
export async function completeSoundHookRetirement(plan, settings) {
|
|
259
|
+
if (!plan?.pending) return { parkCleared: false, scripts: [] };
|
|
260
|
+
let parkCleared = false;
|
|
261
|
+
if (plan.clearPark) {
|
|
262
|
+
parkCleared = await rm(PARKED_PATH, { force: true }).then(() => true, () => false);
|
|
263
|
+
}
|
|
264
|
+
const scripts = [];
|
|
265
|
+
if (!anythingStillNamesOurScripts(settings)) {
|
|
266
|
+
for (const path of OUR_SCRIPTS) {
|
|
267
|
+
if (!existsSync(path)) continue;
|
|
268
|
+
if (await rm(path, { force: true }).then(() => true, () => false)) scripts.push(path);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { parkCleared, scripts };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Retirement for a caller that holds no settings object: `agents-deck
|
|
276
|
+
* --uninstall`, which is taking the deck off the machine rather than upgrading
|
|
277
|
+
* it, and where there is no hook install to ride along with.
|
|
278
|
+
*
|
|
279
|
+
* Same three steps in the same order — read, mutate, write, then clean up — so
|
|
280
|
+
* there is one description of what retirement is rather than two that can drift.
|
|
281
|
+
*/
|
|
282
|
+
export async function retireSoundHook() {
|
|
283
|
+
let settings;
|
|
284
|
+
try {
|
|
285
|
+
({ settings } = await readSettingsForWrite(SETTINGS_PATH));
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err?.code !== "SETTINGS_UNREADABLE") throw err;
|
|
288
|
+
// A file we cannot parse is a file whose contents we cannot reproduce, and
|
|
289
|
+
// this rewrites the whole of it. Left exactly as found, parked hooks still
|
|
290
|
+
// parked, and the user told which file and why.
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: "settings_unreadable",
|
|
294
|
+
settingsPath: SETTINGS_PATH,
|
|
295
|
+
why: err.why ?? err.message,
|
|
296
|
+
message: err?.message ?? String(err),
|
|
297
|
+
removed: 0,
|
|
298
|
+
restored: 0,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const plan = await retireSoundHookIn(settings);
|
|
303
|
+
if (plan.changed) await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
304
|
+
await completeSoundHookRetirement(plan, settings);
|
|
305
|
+
|
|
306
|
+
if (plan.parkError) return { ok: false, ...plan.parkError, removed: plan.removed, restored: plan.restored };
|
|
307
|
+
return { ok: true, removed: plan.removed, restored: plan.restored };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Exported so a test can prove it is pointed at a sandbox before it writes
|
|
311
|
+
// anything — the real ones are the user's own settings and the user's own hooks.
|
|
312
|
+
// The script paths are also the only honest way to ask where the retired script
|
|
313
|
+
// ACTUALLY lived: rebuilding `<config dir>/agent-dag/notify.mjs` inside a test
|
|
314
|
+
// would keep passing on the day this module started looking somewhere else.
|
|
315
|
+
export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
|