@prohost/cli 0.16.6 → 0.16.8

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/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.8
10
+
11
+ - **A dedicated install never takes over a service home that holds another computer.** When the
12
+ service home already held a different computer's key, the install restarted that computer under
13
+ the label, left the invoking home's key where it was, and reported a takeover. It is now refused
14
+ before anything is installed, naming the computer it holds.
15
+ - **A copy that isn't there afterwards is no takeover.** When the key the install copied into the
16
+ service home can't be read back as that computer's, the service is uninstalled and the command
17
+ fails, naming the file to check (no credential is deleted: which file this install wrote can't be
18
+ told from one it didn't). The invoking home keeps its key.
19
+ - **`uninstall` says when a service is still stopping after it gives the key back.** The key goes
20
+ back at once, as before (the service's definition is already gone, so a later run could never
21
+ find it again). A stop that failed or is still draining (`launchctl bootout`, `systemctl disable
22
+ --now`) is then waited for up to 30 s, and a service still stopping fails the command with what
23
+ to wait for before running the computer from that home. A service copy that can't be deleted
24
+ after the give-back fails the command instead of leaving the key in two homes behind a success.
25
+ - **Windows: a scheduled task that couldn't be deleted is a failure.** `uninstall` reported it as
26
+ "No host service was installed" and removed its files, so the key went back while the task could
27
+ start again on it. It now fails, keeps the task's files (a later uninstall still finds it), and
28
+ gives nothing back.
29
+
30
+ ## 0.16.7
31
+
32
+ - **An undo whose cleanup fails says so.** When a dedicated install undoes itself (its hand-off
33
+ note could not be written) but a copy of the key it made can't be removed — on Windows the
34
+ administrators' copy — it reports a partial undo naming each leftover file, instead of "Undone".
35
+ - README: the one-key guarantee names its exception (a service home that already held the key).
36
+ - **Status reports with a terminal session are accepted again.** A live terminal went into
37
+ `running` with its runtime (`claude_code`) as `agent_id` and `started_at: ''`, which the server
38
+ refuses (422) for the whole report, so the computer showed offline. A terminal entry now sends
39
+ `agent_id: null`, `runtime`, and `started_at: null`; every string is clipped to the server's
40
+ limits.
41
+ - **A refused status says why.** The server's reason (a 422 names the field) is logged, secrets
42
+ blanked, at the first refusal and again every 10 minutes with a count while it lasts.
43
+ - The status body is pinned by a contract fixture (`shared/computers/tests/fixtures/cli_status_report.json`)
44
+ that the server's tests validate with `StatusReport`.
45
+
9
46
  ## 0.16.6
10
47
 
11
48
  - **A hand-off note that can't be written undoes the dedicated install.** The note is part of the
@@ -56,6 +93,15 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
56
93
  (`session_unknown`), another or no login (`login_changed`, `login_unavailable`), or past 10 minutes
57
94
  (`restart_expired`); a redelivery of one done is acked as done. Declared as
58
95
  `code_thread_restart_skip_permissions` beside `code_thread_skip_permissions`.
96
+ - **A restarted Claude Code is ready for replies only once its own hook runs.** Each launch in a
97
+ thread's window now has an id (`PROHOST_LAUNCH_ID` in the agent's environment, which its hooks
98
+ inherit), recorded in `code-host/state/<terminal>.launch` as pending until the new process's own
99
+ first hook runs; meanwhile no other hook marks the window ready. A hook of the replaced process
100
+ that finishes after the restart can no longer mark the new one past its first-run screens, so a
101
+ relayed reply never types into its bypass confirmation, however late that hook runs. A launch the
102
+ daemon couldn't confirm stays pending (replies wait) until its own hook runs or the window is
103
+ restarted again. A start or restart whose launch can't be recorded launches nothing. Windows an
104
+ older build started keep the old rule.
59
105
  - Enable disabled macOS services before loading them, and report enable failures instead of claiming the install succeeded.
60
106
 
61
107
  ## 0.16.4
package/README.md CHANGED
@@ -248,8 +248,12 @@ or `$SUDO_USER`'s) keeps only a note — `{handed_to: {label, service_user, serv
248
248
  computer_id, name, account, code_host_home?}`, no key — written as that home's owner.
249
249
  `host status` there reports `handed_to`; `unpair`, `pair` and a personal `install` refuse and
250
250
  name the service. The note is part of the takeover: if it can't be written, the install is
251
- undone (the service uninstalled, the key it copied removed) and fails, so the key is never left in
252
- both homes; a failed install leaves the key where it was. `sudo prohost host uninstall --label <id>` gives the key back to
251
+ undone (the service uninstalled, the key it copied removed) and fails, so it never leaves a second
252
+ copy of the key behind. One exception: when the service home already held the key before this
253
+ install (an older setup that copied it), that copy stays, so the key is in both homes until the
254
+ note can be written — never run `prohost host unpair` from the original home meanwhile; `sudo
255
+ prohost host uninstall --label <id>` then removes the service. A failed install leaves the key
256
+ where it was. `sudo prohost host uninstall --label <id>` gives the key back to
253
257
  that home (so `prohost host install` runs it there again); `sudo prohost host unpair --label <id>`
254
258
  removes the computer. Both read the service's home from its installed definition (root's file),
255
259
  never from the note, which the home's owner can edit. **Updates drain:**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A lock between processes on this Mac: a file beside what it guards, made by hard-linking a private
3
+ * file that already holds its owner's token (so a lock is never seen without one). Its holder
4
+ * releases it only while the file still holds that token, so it never removes a lock another
5
+ * process holds now. A lock left by a process that died holding it is taken over once it is
6
+ * `staleMs` old (see {@link takeOverStale}); with `liveHolders`, a lock whose holder still runs is
7
+ * never taken over, however old (a Mac asleep makes any lock look old).
8
+ */
9
+ /** Whether the lock at `file` was left by a holder that died. */
10
+ type StaleJudge = (file: string, nowMs: number) => boolean;
11
+ /**
12
+ * When process `pid` started, in whole seconds, or `undefined` if that can't be told. From its
13
+ * elapsed time (`ps -o etime`), never a printed date: no time zone to get wrong.
14
+ */
15
+ export declare function processStartSec(pid: number, nowMs?: number): number | undefined;
16
+ /**
17
+ * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
18
+ * judged again. A file that is no longer stale is another process's fresh lock (it took over the
19
+ * same stale one a moment earlier) and is linked back, unless a lock exists again. Judged by age or
20
+ * holder, never by inode: Linux reuses a deleted file's inode for the next one made.
21
+ */
22
+ export declare function takeOverStale(lock: string, staleMs: number, nowMs?: number, stale?: StaleJudge, judged?: string): void;
23
+ export interface FileLockOptions {
24
+ /** A lock older than this was left by a holder that died (with `liveHolders`: see {@link holderGone}). */
25
+ staleMs: number;
26
+ /** Never take over a lock whose holder still runs, however old it is. */
27
+ liveHolders?: boolean;
28
+ /** How long to wait for another holder before giving up. */
29
+ waitMs: number;
30
+ now?: () => number;
31
+ /** When a process started (seconds); `ps` by default. */
32
+ processStart?: (pid: number) => number | undefined;
33
+ }
34
+ /**
35
+ * Run `work` holding `lock`. Returns `{ ok: false }` when another process holds it past
36
+ * `waitMs` (the caller says what that means); errors creating the lock's folder or file throw.
37
+ */
38
+ export declare function withFileLock<T>(lock: string, work: () => T, options: FileLockOptions): {
39
+ ok: true;
40
+ value: T;
41
+ } | {
42
+ ok: false;
43
+ };
44
+ export {};
@@ -0,0 +1,239 @@
1
+ /**
2
+ * A lock between processes on this Mac: a file beside what it guards, made by hard-linking a private
3
+ * file that already holds its owner's token (so a lock is never seen without one). Its holder
4
+ * releases it only while the file still holds that token, so it never removes a lock another
5
+ * process holds now. A lock left by a process that died holding it is taken over once it is
6
+ * `staleMs` old (see {@link takeOverStale}); with `liveHolders`, a lock whose holder still runs is
7
+ * never taken over, however old (a Mac asleep makes any lock look old).
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { linkSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { ensurePrivateDir } from './settings.js';
14
+ const olderThan = (staleMs) => (file, nowMs) => nowMs - statSync(file).mtimeMs > staleMs;
15
+ let ownStart;
16
+ /**
17
+ * When this process started, in whole seconds, as `ps` tells it (the same source a waiter checks it
18
+ * against, so a clock correction since can't set them apart); asked once per process. `0` (none)
19
+ * when `ps` can't tell: such a lock is then judged by its holder's pid alone.
20
+ */
21
+ function ownStartSec() {
22
+ ownStart ??= processStartSec(process.pid) ?? 0;
23
+ return ownStart;
24
+ }
25
+ /** `<pid>.<start seconds>.<random>`: who holds the lock, and (with `withStart`) which incarnation of that pid. */
26
+ function newToken(withStart) {
27
+ return `${process.pid}.${withStart ? ownStartSec() : 0}.${Math.random().toString(16).slice(2)}`;
28
+ }
29
+ /** The holder named in the lock at `file`, or `undefined` if its token can't be read. */
30
+ function holderOf(file) {
31
+ try {
32
+ const [pidText, startText] = readFileSync(file, 'utf8').split('.');
33
+ const pid = Number.parseInt(pidText ?? '', 10);
34
+ if (!Number.isInteger(pid) || pid <= 0)
35
+ return undefined;
36
+ const startSec = Number.parseInt(startText ?? '', 10);
37
+ return { pid, startSec: Number.isInteger(startSec) && startSec > 0 ? startSec : undefined };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ function readToken(file) {
44
+ try {
45
+ return readFileSync(file, 'utf8');
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function running(pid) {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ }
56
+ catch (err) {
57
+ return err.code === 'EPERM';
58
+ }
59
+ }
60
+ /** `[[dd-]hh:]mm:ss`, as `ps -o etime` prints it, in seconds. */
61
+ function elapsedSec(text) {
62
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text.trim());
63
+ if (!match)
64
+ return undefined;
65
+ const [, days, hours, minutes, seconds] = match;
66
+ return ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
67
+ }
68
+ /**
69
+ * When process `pid` started, in whole seconds, or `undefined` if that can't be told. From its
70
+ * elapsed time (`ps -o etime`), never a printed date: no time zone to get wrong.
71
+ */
72
+ export function processStartSec(pid, nowMs = Date.now()) {
73
+ try {
74
+ const out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], {
75
+ encoding: 'utf8',
76
+ timeout: 1_000,
77
+ stdio: ['ignore', 'pipe', 'ignore'],
78
+ });
79
+ const elapsed = elapsedSec(out);
80
+ return elapsed === undefined ? undefined : Math.round(nowMs / 1000) - elapsed;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /** When this Mac last booted, in ms. */
87
+ const bootedAtMs = () => Date.now() - os.uptime() * 1000;
88
+ /**
89
+ * Stale only once its holder is gone. A holder that still runs (suspended, say, while the Mac slept)
90
+ * keeps its lock. Gone: a lock from before the last boot; a holder pid that runs no process; or, for
91
+ * a lock past `staleMs`, a pid that now belongs to another process (it started at another time). A
92
+ * lock whose token can't be read is judged by age.
93
+ */
94
+ const holderGone = (staleMs, startOf) => (file, nowMs) => {
95
+ const { mtimeMs } = statSync(file);
96
+ if (mtimeMs < bootedAtMs() - 60_000)
97
+ return true;
98
+ const holder = holderOf(file);
99
+ if (holder === undefined)
100
+ return nowMs - mtimeMs > staleMs;
101
+ if (!running(holder.pid))
102
+ return true;
103
+ if (nowMs - mtimeMs <= staleMs || holder.startSec === undefined)
104
+ return false;
105
+ // Old, and its pid runs: the same process only if it started when the holder did.
106
+ const started = startOf(holder.pid);
107
+ return started !== undefined && Math.abs(started - holder.startSec) > 2;
108
+ };
109
+ /**
110
+ * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
111
+ * judged again. A file that is no longer stale is another process's fresh lock (it took over the
112
+ * same stale one a moment earlier) and is linked back, unless a lock exists again. Judged by age or
113
+ * holder, never by inode: Linux reuses a deleted file's inode for the next one made.
114
+ */
115
+ export function takeOverStale(lock, staleMs, nowMs = Date.now(), stale = olderThan(staleMs), judged) {
116
+ const aside = `${lock}.${process.pid}.${Math.random().toString(16).slice(2)}.stale`;
117
+ try {
118
+ renameSync(lock, aside);
119
+ }
120
+ catch {
121
+ return; // someone else moved or released it
122
+ }
123
+ try {
124
+ // Not the lock that was judged (`judged`: its token then): a successor took the path meanwhile,
125
+ // and its lock goes straight back, unjudged.
126
+ if ((judged !== undefined && readToken(aside) !== judged) || !stale(aside, nowMs)) {
127
+ try {
128
+ linkSync(aside, lock);
129
+ }
130
+ catch {
131
+ /* a newer lock is there: that one stands */
132
+ }
133
+ }
134
+ }
135
+ finally {
136
+ rmSync(aside, { force: true });
137
+ }
138
+ }
139
+ /**
140
+ * Drafts (`<lock>.<token>.tmp`) a holder left behind when it died between writing one and removing
141
+ * it: removed once older than `staleMs` and their writer runs no more. Run by a holder, after it
142
+ * has the lock, so the state folder never fills up with them.
143
+ */
144
+ function sweepDrafts(lock, staleMs, nowMs) {
145
+ const dir = path.dirname(lock);
146
+ const prefix = `${path.basename(lock)}.`;
147
+ let names;
148
+ try {
149
+ names = readdirSync(dir);
150
+ }
151
+ catch {
152
+ return;
153
+ }
154
+ for (const name of names) {
155
+ if (!name.startsWith(prefix) || !(name.endsWith('.tmp') || name.endsWith('.stale')))
156
+ continue;
157
+ const file = path.join(dir, name);
158
+ try {
159
+ if (nowMs - statSync(file).mtimeMs <= staleMs)
160
+ continue;
161
+ const pid = Number.parseInt(name.slice(prefix.length).split('.')[0] ?? '', 10);
162
+ if (Number.isInteger(pid) && pid > 0 && running(pid))
163
+ continue;
164
+ rmSync(file, { force: true });
165
+ }
166
+ catch {
167
+ /* gone meanwhile */
168
+ }
169
+ }
170
+ }
171
+ function sleepSync(ms) {
172
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
173
+ }
174
+ /**
175
+ * Run `work` holding `lock`. Returns `{ ok: false }` when another process holds it past
176
+ * `waitMs` (the caller says what that means); errors creating the lock's folder or file throw.
177
+ */
178
+ export function withFileLock(lock, work, options) {
179
+ ensurePrivateDir(path.dirname(lock));
180
+ const now = options.now ?? Date.now;
181
+ const deadline = now() + options.waitMs;
182
+ const stale = options.liveHolders
183
+ ? holderGone(options.staleMs, options.processStart ?? processStartSec)
184
+ : olderThan(options.staleMs);
185
+ // Released only while the file is still this one: never a lock another process holds now.
186
+ const token = newToken(options.liveHolders === true);
187
+ // The token is written first, privately; the lock appears only as a link to it, whole.
188
+ const draft = `${lock}.${token}.tmp`;
189
+ writeFileSync(draft, token, { mode: 0o600, flag: 'wx' });
190
+ try {
191
+ for (;;) {
192
+ try {
193
+ linkSync(draft, lock);
194
+ // The link carries the draft's age: made fresh now, then checked still ours (a waiter that
195
+ // judged it by that age a moment earlier may have taken it over; then this one tries again).
196
+ const at = new Date(now());
197
+ utimesSync(lock, at, at);
198
+ if (readFileSync(lock, 'utf8') === token)
199
+ break;
200
+ continue;
201
+ }
202
+ catch (err) {
203
+ if (err.code === 'ENOENT')
204
+ continue; // taken over meanwhile
205
+ if (err.code !== 'EEXIST')
206
+ throw err;
207
+ }
208
+ try {
209
+ const judged = readToken(lock);
210
+ if (stale(lock, now())) {
211
+ takeOverStale(lock, options.staleMs, now(), stale, judged);
212
+ continue;
213
+ }
214
+ }
215
+ catch {
216
+ continue; // released between the link and the stat: try again at once
217
+ }
218
+ if (now() >= deadline)
219
+ return { ok: false };
220
+ sleepSync(10 + Math.floor(Math.random() * 20));
221
+ }
222
+ }
223
+ finally {
224
+ rmSync(draft, { force: true });
225
+ }
226
+ sweepDrafts(lock, options.staleMs, now());
227
+ try {
228
+ return { ok: true, value: work() };
229
+ }
230
+ finally {
231
+ try {
232
+ if (readFileSync(lock, 'utf8') === token)
233
+ rmSync(lock, { force: true });
234
+ }
235
+ catch {
236
+ /* already gone */
237
+ }
238
+ }
239
+ }
@@ -65,19 +65,14 @@ export declare const TOUCH_LOCK_WAIT_MS = 100;
65
65
  export declare class FoldersBusyError extends Error {
66
66
  constructor();
67
67
  }
68
- /**
69
- * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
70
- * judged by its age again. A file that is no longer stale is another process's fresh lock (it took
71
- * over the same stale one a moment earlier) and is linked back, unless a lock exists again. Judged
72
- * by age, never by inode: Linux reuses a deleted file's inode for the next one made.
73
- */
68
+ /** {@link takeOverStale} for the folders lock (its stale age). */
74
69
  export declare function takeOverStale(lock: string, nowMs?: number): void;
75
70
  /**
76
71
  * Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
77
- * created exclusively. The desktop app's `folders add`, a second `folders add` and the daemon
78
- * moving a started folder to the front are separate processes; without it, two that read the same
79
- * list each write it back without the other's folder. A lock left by a process that died is taken
80
- * over once it is {@link FOLDERS_LOCK_STALE_MS} old.
72
+ * created exclusively ({@link withFileLock}). The desktop app's `folders add`, a second `folders add`
73
+ * and the daemon moving a started folder to the front are separate processes; without it, two that
74
+ * read the same list each write it back without the other's folder. A lock left by a process that
75
+ * died is taken over once it is {@link FOLDERS_LOCK_STALE_MS} old.
81
76
  *
82
77
  * @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
83
78
  */
@@ -19,10 +19,11 @@
19
19
  * segments: a symlink inside an opened folder that points out of it does not
20
20
  * count as inside.
21
21
  */
22
- import { closeSync, linkSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
22
+ import { realpathSync, statSync } from 'node:fs';
23
23
  import os from 'node:os';
24
24
  import path from 'node:path';
25
- import { codeHostDir, ensurePrivateDir, readJsonObject, writePrivateJson } from './settings.js';
25
+ import { takeOverStale as takeOverStaleLock, withFileLock } from './filelock.js';
26
+ import { codeHostDir, readJsonObject, writePrivateJson } from './settings.js';
26
27
  export const FOLDERS_VERSION = 1;
27
28
  export const MAX_OPENED_FOLDERS = 50;
28
29
  /** The server's bound on a start's `folder` (CONTRACT: ≤ 1024 chars). */
@@ -109,93 +110,28 @@ export class FoldersBusyError extends Error {
109
110
  super('The opened-folders list is busy (another prohost is changing it). Try again.');
110
111
  }
111
112
  }
112
- /**
113
- * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
114
- * judged by its age again. A file that is no longer stale is another process's fresh lock (it took
115
- * over the same stale one a moment earlier) and is linked back, unless a lock exists again. Judged
116
- * by age, never by inode: Linux reuses a deleted file's inode for the next one made.
117
- */
113
+ /** {@link takeOverStale} for the folders lock (its stale age). */
118
114
  export function takeOverStale(lock, nowMs = Date.now()) {
119
- const aside = `${lock}.${process.pid}.${Math.random().toString(16).slice(2)}.stale`;
120
- try {
121
- renameSync(lock, aside);
122
- }
123
- catch {
124
- return; // someone else moved or released it
125
- }
126
- try {
127
- if (nowMs - statSync(aside).mtimeMs <= FOLDERS_LOCK_STALE_MS) {
128
- try {
129
- linkSync(aside, lock);
130
- }
131
- catch {
132
- /* a newer lock is there: that one stands */
133
- }
134
- }
135
- }
136
- finally {
137
- rmSync(aside, { force: true });
138
- }
139
- }
140
- function sleepSync(ms) {
141
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
+ takeOverStaleLock(lock, FOLDERS_LOCK_STALE_MS, nowMs);
142
116
  }
143
117
  /**
144
118
  * Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
145
- * created exclusively. The desktop app's `folders add`, a second `folders add` and the daemon
146
- * moving a started folder to the front are separate processes; without it, two that read the same
147
- * list each write it back without the other's folder. A lock left by a process that died is taken
148
- * over once it is {@link FOLDERS_LOCK_STALE_MS} old.
119
+ * created exclusively ({@link withFileLock}). The desktop app's `folders add`, a second `folders add`
120
+ * and the daemon moving a started folder to the front are separate processes; without it, two that
121
+ * read the same list each write it back without the other's folder. A lock left by a process that
122
+ * died is taken over once it is {@link FOLDERS_LOCK_STALE_MS} old.
149
123
  *
150
124
  * @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
151
125
  */
152
126
  export function withFoldersLock(env, work, options = {}) {
153
- const lock = `${foldersPath(env)}.lock`;
154
- ensurePrivateDir(path.dirname(lock));
155
- const now = options.now ?? Date.now;
156
- const deadline = now() + (options.waitMs ?? FOLDERS_LOCK_WAIT_MS);
157
- // Released only while the file is still this one: never a lock another process holds now.
158
- const token = `${process.pid}.${Math.random().toString(16).slice(2)}`;
159
- for (;;) {
160
- try {
161
- const fd = openSync(lock, 'wx', 0o600);
162
- try {
163
- writeSync(fd, token);
164
- }
165
- finally {
166
- closeSync(fd);
167
- }
168
- break;
169
- }
170
- catch (err) {
171
- if (err.code !== 'EEXIST')
172
- throw err;
173
- }
174
- try {
175
- if (now() - statSync(lock).mtimeMs > FOLDERS_LOCK_STALE_MS) {
176
- takeOverStale(lock, now());
177
- continue;
178
- }
179
- }
180
- catch {
181
- continue; // released between the open and the stat: try again at once
182
- }
183
- if (now() >= deadline)
184
- throw new FoldersBusyError();
185
- sleepSync(10 + Math.floor(Math.random() * 20));
186
- }
187
- try {
188
- return work();
189
- }
190
- finally {
191
- try {
192
- if (readFileSync(lock, 'utf8') === token)
193
- rmSync(lock, { force: true });
194
- }
195
- catch {
196
- /* already gone */
197
- }
198
- }
127
+ const held = withFileLock(`${foldersPath(env)}.lock`, work, {
128
+ staleMs: FOLDERS_LOCK_STALE_MS,
129
+ waitMs: options.waitMs ?? FOLDERS_LOCK_WAIT_MS,
130
+ now: options.now,
131
+ });
132
+ if (!held.ok)
133
+ throw new FoldersBusyError();
134
+ return held.value;
199
135
  }
200
136
  /** `folder` (a realpath) at the front, opened now; the oldest past the cap drops off. */
201
137
  function upsertFront(folder, env, now, waitMs) {
@@ -8,23 +8,86 @@
8
8
  * screen, is what lets a relayed reply be typed: before it, the dialog may be
9
9
  * up or about to be drawn, and an Enter would answer it. The mark is a file,
10
10
  * so it outlives a daemon restart.
11
+ *
12
+ * A window's Claude Code can be replaced (a restart), and a hook of the old one
13
+ * may still be running when it is: one that writes the mark after the restart
14
+ * dropped it would say the new process is past screens it hasn't reached. So
15
+ * each launch has an id ({@link LAUNCH_ENV} in the process's environment, which
16
+ * its hooks inherit), recorded in `<state>/<terminal>.launch` as pending until
17
+ * the new process's own first hook runs; meanwhile no other hook marks the
18
+ * window, however late it runs. A launch the daemon couldn't confirm stays
19
+ * pending (no reply types) until its own hook runs or the window is restarted
20
+ * again. A window with no launch record (one an older build started) is judged
21
+ * by the mark alone, as before.
22
+ */
23
+ /** The launch id a window's agent process runs under, in its environment (and so its hooks'). */
24
+ export declare const LAUNCH_ENV = "PROHOST_LAUNCH_ID";
25
+ /**
26
+ * `<state>/<terminal>.launch`: the launch the window runs (`launch`; `null` for a process with no
27
+ * launch id, such as one an older build started), and `next`, one about to replace it whose own
28
+ * hook hasn't run yet. `gen` changes with every write, and a mark counts only for the `gen` it was
29
+ * written under, so a mark whose writer looked before a launch began never counts after it.
11
30
  */
31
+ interface LaunchRecord {
32
+ launch: string | null;
33
+ next?: string;
34
+ gen: number;
35
+ }
12
36
  export declare function readyPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
37
+ export declare function launchPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
13
38
  /**
14
- * Mark the terminal's Claude Code past its trust dialog (a hook ran). Atomic (`writePrivateJson`).
15
- * Returns whether it is marked afterwards; `false` means the write failed, and the caller says so.
39
+ * The launch the window runs now: its id, `null` for a process with no launch id or a window with no
40
+ * launch record, or `undefined` when the record can't be read.
41
+ */
42
+ export declare function currentLaunch(terminalId: string, env?: NodeJS.ProcessEnv): string | null | undefined;
43
+ /** The launch waiting for its first hook, if one is. */
44
+ export declare function pendingLaunch(terminalId: string, env?: NodeJS.ProcessEnv): string | undefined;
45
+ /**
46
+ * A hook of launch `launch` ran in the window (by default the caller's own, from its environment):
47
+ * mark the window past its trust dialog. Atomic (`writePrivateJson`).
48
+ *
49
+ * - The pending launch's own hook proves it runs: it becomes the window's launch, and is marked.
50
+ * - While a launch is pending, any other hook is of a process being replaced: it has no say, however
51
+ * late it runs (a hook suspended while the Mac slept may resume long after).
52
+ * - With nothing pending, a hook of another launch is of the process the window runs (an older
53
+ * build relaunched it since): its launch becomes the window's.
54
+ *
55
+ * Returns whether the window is marked afterwards, or that the hook had no say; `false` means the
56
+ * state couldn't be read or written, and the caller says so.
16
57
  */
17
- export declare function markReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
58
+ export declare function markReady(terminalId: string, env?: NodeJS.ProcessEnv, launch?: string | null, now?: number): boolean;
18
59
  /**
19
- * Forget the mark: the window's Claude Code was replaced (a restart), and the new one is past its
20
- * dialog only once its own first hook runs. Returns whether no mark is left.
60
+ * Forget the mark: the window's Claude Code is being replaced, and the new one is past its dialog
61
+ * only once its own first hook runs. Returns whether no mark is left.
21
62
  */
22
63
  export declare function clearReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
64
+ /**
65
+ * Launch `launch` is about to run in the window (a start or a restart): pending until its own first
66
+ * hook runs, and no other hook marks the window meanwhile. Recorded first, then the old mark dropped.
67
+ * Returns whether both were done; on `false` the caller must not launch.
68
+ */
69
+ export declare function beginLaunch(terminalId: string, launch: string, env?: NodeJS.ProcessEnv, now?: number): boolean;
70
+ /** What {@link restoreLaunch} puts back: the window's launch record and readiness before {@link beginLaunch}. */
71
+ export interface LaunchState {
72
+ record: LaunchRecord | null | undefined;
73
+ ready: boolean | undefined;
74
+ }
75
+ export declare function launchState(terminalId: string, env?: NodeJS.ProcessEnv): LaunchState;
76
+ /**
77
+ * The new launch never ran (nothing was replaced): the window's old process keeps its launch and,
78
+ * if it had it, its readiness. If that can't be written, the launch stays pending (no reply types)
79
+ * until the window is restarted again.
80
+ */
81
+ export declare function restoreLaunch(terminalId: string, state: LaunchState, env?: NodeJS.ProcessEnv, now?: number): void;
23
82
  /**
24
83
  * The transcript files (`<session>.jsonl`) Claude Code keeps for `cwd` under `configDir`: one exists
25
84
  * for a session only once its first prompt ran. Empty when there are none or they can't be read.
26
85
  */
27
86
  export declare function claudeTranscripts(cwd: string, configDir: string): string[];
28
87
  export declare function claudeSessionSince(cwd: string, configDir: string, sinceMs: number): boolean;
29
- /** `true` past the dialog, `false` not yet, `undefined` when the state can't be read. */
88
+ /**
89
+ * `true` past the dialog (a hook of the launch the window runs now ran, and no other launch is
90
+ * pending), `false` not yet, `undefined` when the state can't be read.
91
+ */
30
92
  export declare function isReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean | undefined;
93
+ export {};