@prohost/cli 0.16.6 → 0.16.7
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 +25 -0
- package/README.md +6 -2
- package/dist/codehost/filelock.d.ts +44 -0
- package/dist/codehost/filelock.js +239 -0
- package/dist/codehost/folders.d.ts +5 -10
- package/dist/codehost/folders.js +17 -81
- package/dist/codehost/ready.d.ts +69 -6
- package/dist/codehost/ready.js +212 -11
- package/dist/codehost/terminals.js +31 -13
- package/dist/host/api.js +1 -1
- package/dist/host/command.d.ts +5 -0
- package/dist/host/command.js +28 -12
- package/dist/host/service.d.ts +4 -0
- package/dist/host/service.js +25 -4
- package/dist/host/status.d.ts +6 -2
- package/dist/host/status.js +26 -8
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.16.7
|
|
10
|
+
|
|
11
|
+
- **An undo whose cleanup fails says so.** When a dedicated install undoes itself (its hand-off
|
|
12
|
+
note could not be written) but a copy of the key it made can't be removed — on Windows the
|
|
13
|
+
administrators' copy — it reports a partial undo naming each leftover file, instead of "Undone".
|
|
14
|
+
- README: the one-key guarantee names its exception (a service home that already held the key).
|
|
15
|
+
- **Status reports with a terminal session are accepted again.** A live terminal went into
|
|
16
|
+
`running` with its runtime (`claude_code`) as `agent_id` and `started_at: ''`, which the server
|
|
17
|
+
refuses (422) for the whole report, so the computer showed offline. A terminal entry now sends
|
|
18
|
+
`agent_id: null`, `runtime`, and `started_at: null`; every string is clipped to the server's
|
|
19
|
+
limits.
|
|
20
|
+
- **A refused status says why.** The server's reason (a 422 names the field) is logged, secrets
|
|
21
|
+
blanked, at the first refusal and again every 10 minutes with a count while it lasts.
|
|
22
|
+
- The status body is pinned by a contract fixture (`shared/computers/tests/fixtures/cli_status_report.json`)
|
|
23
|
+
that the server's tests validate with `StatusReport`.
|
|
24
|
+
|
|
9
25
|
## 0.16.6
|
|
10
26
|
|
|
11
27
|
- **A hand-off note that can't be written undoes the dedicated install.** The note is part of the
|
|
@@ -56,6 +72,15 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
|
56
72
|
(`session_unknown`), another or no login (`login_changed`, `login_unavailable`), or past 10 minutes
|
|
57
73
|
(`restart_expired`); a redelivery of one done is acked as done. Declared as
|
|
58
74
|
`code_thread_restart_skip_permissions` beside `code_thread_skip_permissions`.
|
|
75
|
+
- **A restarted Claude Code is ready for replies only once its own hook runs.** Each launch in a
|
|
76
|
+
thread's window now has an id (`PROHOST_LAUNCH_ID` in the agent's environment, which its hooks
|
|
77
|
+
inherit), recorded in `code-host/state/<terminal>.launch` as pending until the new process's own
|
|
78
|
+
first hook runs; meanwhile no other hook marks the window ready. A hook of the replaced process
|
|
79
|
+
that finishes after the restart can no longer mark the new one past its first-run screens, so a
|
|
80
|
+
relayed reply never types into its bypass confirmation, however late that hook runs. A launch the
|
|
81
|
+
daemon couldn't confirm stays pending (replies wait) until its own hook runs or the window is
|
|
82
|
+
restarted again. A start or restart whose launch can't be recorded launches nothing. Windows an
|
|
83
|
+
older build started keep the old rule.
|
|
59
84
|
- Enable disabled macOS services before loading them, and report enable failures instead of claiming the install succeeded.
|
|
60
85
|
|
|
61
86
|
## 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
|
|
252
|
-
|
|
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`
|
|
78
|
-
* moving a started folder to the front are separate processes; without it, two that
|
|
79
|
-
* list each write it back without the other's folder. A lock left by a process that
|
|
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
|
*/
|
package/dist/codehost/folders.js
CHANGED
|
@@ -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 {
|
|
22
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
23
23
|
import os from 'node:os';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
-
import {
|
|
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
|
-
|
|
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`
|
|
146
|
-
* moving a started folder to the front are separate processes; without it, two that
|
|
147
|
-
* list each write it back without the other's folder. A lock left by a process that
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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) {
|
package/dist/codehost/ready.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
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 {};
|
package/dist/codehost/ready.js
CHANGED
|
@@ -8,27 +8,151 @@
|
|
|
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.
|
|
11
22
|
*/
|
|
12
|
-
import { closeSync, openSync, readdirSync, readSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
23
|
+
import { closeSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
13
24
|
import path from 'node:path';
|
|
25
|
+
import { withFileLock } from './filelock.js';
|
|
14
26
|
import { codeHostDir, writePrivateJson } from './settings.js';
|
|
15
27
|
import { isValidId } from './tmux.js';
|
|
28
|
+
/** The launch id a window's agent process runs under, in its environment (and so its hooks'). */
|
|
29
|
+
export const LAUNCH_ENV = 'PROHOST_LAUNCH_ID';
|
|
16
30
|
export function readyPath(terminalId, env = process.env) {
|
|
17
31
|
if (!isValidId(terminalId))
|
|
18
32
|
throw new Error('invalid terminal id');
|
|
19
33
|
return path.join(codeHostDir(env), 'state', `${terminalId}.ready`);
|
|
20
34
|
}
|
|
35
|
+
export function launchPath(terminalId, env = process.env) {
|
|
36
|
+
if (!isValidId(terminalId))
|
|
37
|
+
throw new Error('invalid terminal id');
|
|
38
|
+
return path.join(codeHostDir(env), 'state', `${terminalId}.launch`);
|
|
39
|
+
}
|
|
40
|
+
/** The window's launch record: `null` with none (an older build's window), `undefined` when it can't be read. */
|
|
41
|
+
function readLaunch(terminalId, env) {
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = readFileSync(launchPath(terminalId, env), 'utf8');
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return err.code === 'ENOENT' ? null : undefined;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
const launch = parsed.launch;
|
|
52
|
+
if (!(launch === null || typeof launch === 'string') || typeof parsed.gen !== 'number')
|
|
53
|
+
return undefined;
|
|
54
|
+
if (parsed.next !== undefined && typeof parsed.next !== 'string')
|
|
55
|
+
return undefined;
|
|
56
|
+
return { launch, gen: parsed.gen, ...(parsed.next !== undefined ? { next: parsed.next } : {}) };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A lock whose token can't be read (its holder died writing it) and older than this is stale. */
|
|
63
|
+
const LAUNCH_LOCK_STALE_MS = 10_000;
|
|
64
|
+
/** How long a writer waits for the lock before it gives up (and says so). */
|
|
65
|
+
const LAUNCH_LOCK_WAIT_MS = 1_000;
|
|
66
|
+
/**
|
|
67
|
+
* `fn` holding the window's launch lock (`<terminal>.launch.lock`, {@link withFileLock}): hooks and
|
|
68
|
+
* the daemon are separate processes, and every read-then-write of the record goes through it, so a
|
|
69
|
+
* hook's promotion never overwrites a launch the daemon began after the hook read the record.
|
|
70
|
+
* `fallback` when the lock can't be had.
|
|
71
|
+
*/
|
|
72
|
+
function withLaunchLock(terminalId, env, fallback, fn) {
|
|
73
|
+
try {
|
|
74
|
+
// A holder that still runs keeps it, however long it is suspended: taken over, it would write
|
|
75
|
+
// over its successor's record on waking.
|
|
76
|
+
const held = withFileLock(`${launchPath(terminalId, env)}.lock`, fn, {
|
|
77
|
+
staleMs: LAUNCH_LOCK_STALE_MS,
|
|
78
|
+
waitMs: LAUNCH_LOCK_WAIT_MS,
|
|
79
|
+
liveHolders: true,
|
|
80
|
+
});
|
|
81
|
+
return held.ok ? held.value : fallback;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** A gen that is new: never equal to the one it replaces, even within one millisecond. */
|
|
88
|
+
function nextGen(previous, now) {
|
|
89
|
+
return previous && now <= previous.gen ? previous.gen + 1 : now;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The launch the window runs now: its id, `null` for a process with no launch id or a window with no
|
|
93
|
+
* launch record, or `undefined` when the record can't be read.
|
|
94
|
+
*/
|
|
95
|
+
export function currentLaunch(terminalId, env = process.env) {
|
|
96
|
+
const record = readLaunch(terminalId, env);
|
|
97
|
+
return record === undefined ? undefined : (record?.launch ?? null);
|
|
98
|
+
}
|
|
99
|
+
/** The launch waiting for its first hook, if one is. */
|
|
100
|
+
export function pendingLaunch(terminalId, env = process.env) {
|
|
101
|
+
return readLaunch(terminalId, env)?.next;
|
|
102
|
+
}
|
|
21
103
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
104
|
+
* A hook of launch `launch` ran in the window (by default the caller's own, from its environment):
|
|
105
|
+
* mark the window past its trust dialog. Atomic (`writePrivateJson`).
|
|
106
|
+
*
|
|
107
|
+
* - The pending launch's own hook proves it runs: it becomes the window's launch, and is marked.
|
|
108
|
+
* - While a launch is pending, any other hook is of a process being replaced: it has no say, however
|
|
109
|
+
* late it runs (a hook suspended while the Mac slept may resume long after).
|
|
110
|
+
* - With nothing pending, a hook of another launch is of the process the window runs (an older
|
|
111
|
+
* build relaunched it since): its launch becomes the window's.
|
|
112
|
+
*
|
|
113
|
+
* Returns whether the window is marked afterwards, or that the hook had no say; `false` means the
|
|
114
|
+
* state couldn't be read or written, and the caller says so.
|
|
24
115
|
*/
|
|
25
|
-
export function markReady(terminalId, env = process.env) {
|
|
116
|
+
export function markReady(terminalId, env = process.env, launch = env[LAUNCH_ENV] || null, now = Date.now()) {
|
|
26
117
|
if (!isValidId(terminalId))
|
|
27
118
|
return false;
|
|
28
|
-
|
|
119
|
+
// Most hooks find their window marked already: nothing to write, so no lock to take. That
|
|
120
|
+
// includes a window an older build started (no record), whose hooks run this build after an upgrade.
|
|
121
|
+
const record = readLaunch(terminalId, env);
|
|
122
|
+
const current = record === null || (record !== undefined && record.next === undefined && record.launch === launch);
|
|
123
|
+
if (current && isReady(terminalId, env) === true)
|
|
29
124
|
return true;
|
|
125
|
+
return withLaunchLock(terminalId, env, false, () => markReadyLocked(terminalId, env, launch, now));
|
|
126
|
+
}
|
|
127
|
+
function markReadyLocked(terminalId, env, launch, now) {
|
|
128
|
+
const record = readLaunch(terminalId, env);
|
|
129
|
+
if (record === undefined)
|
|
130
|
+
return false;
|
|
30
131
|
try {
|
|
31
|
-
|
|
132
|
+
if (record === null) {
|
|
133
|
+
// A window an older build started: the mark alone, as it always was.
|
|
134
|
+
if (isReady(terminalId, env) === true)
|
|
135
|
+
return true;
|
|
136
|
+
writePrivateJson(readyPath(terminalId, env), { launch, at: now });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
let current = record;
|
|
140
|
+
if (record.next !== undefined) {
|
|
141
|
+
// Pending: only its own hook settles it (or the daemon, knowing nothing was replaced).
|
|
142
|
+
if (launch !== record.next)
|
|
143
|
+
return true;
|
|
144
|
+
current = { launch, gen: nextGen(record, now) };
|
|
145
|
+
}
|
|
146
|
+
else if (launch !== record.launch) {
|
|
147
|
+
// Nothing pending, and a hook of another launch: the window runs it (an older build relaunched
|
|
148
|
+
// it since), and a hook runs only once it is past its screens.
|
|
149
|
+
current = { launch, gen: nextGen(record, now) };
|
|
150
|
+
}
|
|
151
|
+
if (current !== record)
|
|
152
|
+
writePrivateJson(launchPath(terminalId, env), current);
|
|
153
|
+
else if (isReady(terminalId, env) === true)
|
|
154
|
+
return true;
|
|
155
|
+
writePrivateJson(readyPath(terminalId, env), { launch: current.launch, gen: current.gen, at: now });
|
|
32
156
|
return true;
|
|
33
157
|
}
|
|
34
158
|
catch {
|
|
@@ -36,8 +160,8 @@ export function markReady(terminalId, env = process.env) {
|
|
|
36
160
|
}
|
|
37
161
|
}
|
|
38
162
|
/**
|
|
39
|
-
* Forget the mark: the window's Claude Code
|
|
40
|
-
*
|
|
163
|
+
* Forget the mark: the window's Claude Code is being replaced, and the new one is past its dialog
|
|
164
|
+
* only once its own first hook runs. Returns whether no mark is left.
|
|
41
165
|
*/
|
|
42
166
|
export function clearReady(terminalId, env = process.env) {
|
|
43
167
|
if (!isValidId(terminalId))
|
|
@@ -50,6 +174,65 @@ export function clearReady(terminalId, env = process.env) {
|
|
|
50
174
|
return false;
|
|
51
175
|
}
|
|
52
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Launch `launch` is about to run in the window (a start or a restart): pending until its own first
|
|
179
|
+
* hook runs, and no other hook marks the window meanwhile. Recorded first, then the old mark dropped.
|
|
180
|
+
* Returns whether both were done; on `false` the caller must not launch.
|
|
181
|
+
*/
|
|
182
|
+
export function beginLaunch(terminalId, launch, env = process.env, now = Date.now()) {
|
|
183
|
+
if (!isValidId(terminalId) || launch === '')
|
|
184
|
+
return false;
|
|
185
|
+
return withLaunchLock(terminalId, env, false, () => {
|
|
186
|
+
const record = readLaunch(terminalId, env);
|
|
187
|
+
if (record === undefined)
|
|
188
|
+
return false;
|
|
189
|
+
try {
|
|
190
|
+
writePrivateJson(launchPath(terminalId, env), { launch: record?.launch ?? null, next: launch, gen: nextGen(record, now) });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return clearReady(terminalId, env);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export function launchState(terminalId, env = process.env) {
|
|
199
|
+
return { record: readLaunch(terminalId, env), ready: isReady(terminalId, env) };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The new launch never ran (nothing was replaced): the window's old process keeps its launch and,
|
|
203
|
+
* if it had it, its readiness. If that can't be written, the launch stays pending (no reply types)
|
|
204
|
+
* until the window is restarted again.
|
|
205
|
+
*/
|
|
206
|
+
export function restoreLaunch(terminalId, state, env = process.env, now = Date.now()) {
|
|
207
|
+
const before = state.record;
|
|
208
|
+
if (before === undefined)
|
|
209
|
+
return;
|
|
210
|
+
withLaunchLock(terminalId, env, undefined, () => restoreLocked(terminalId, before, state.ready === true, env, now));
|
|
211
|
+
}
|
|
212
|
+
function restoreLocked(terminalId, previous, ready, env, now) {
|
|
213
|
+
try {
|
|
214
|
+
if (previous === null) {
|
|
215
|
+
rmSync(launchPath(terminalId, env), { force: true });
|
|
216
|
+
if (ready)
|
|
217
|
+
writePrivateJson(readyPath(terminalId, env), { launch: null, at: now });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const pending = readLaunch(terminalId, env);
|
|
221
|
+
// Exactly as it was, a launch still pending included: a failed attempt never lifts its protection.
|
|
222
|
+
const record = {
|
|
223
|
+
launch: previous.launch,
|
|
224
|
+
...(previous.next !== undefined ? { next: previous.next } : {}),
|
|
225
|
+
gen: nextGen(pending ?? previous, now),
|
|
226
|
+
};
|
|
227
|
+
writePrivateJson(launchPath(terminalId, env), record);
|
|
228
|
+
if (ready && record.next === undefined) {
|
|
229
|
+
writePrivateJson(readyPath(terminalId, env), { launch: record.launch, gen: record.gen, at: now });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* left pending (see above): fails closed */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
53
236
|
/** How much of a transcript is read looking for its folder (its first entries carry `cwd`). */
|
|
54
237
|
const TRANSCRIPT_HEAD_BYTES = 256 * 1024;
|
|
55
238
|
/**
|
|
@@ -129,13 +312,31 @@ export function claudeSessionSince(cwd, configDir, sinceMs) {
|
|
|
129
312
|
}
|
|
130
313
|
});
|
|
131
314
|
}
|
|
132
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* `true` past the dialog (a hook of the launch the window runs now ran, and no other launch is
|
|
317
|
+
* pending), `false` not yet, `undefined` when the state can't be read.
|
|
318
|
+
*/
|
|
133
319
|
export function isReady(terminalId, env = process.env) {
|
|
320
|
+
let raw;
|
|
134
321
|
try {
|
|
135
|
-
|
|
136
|
-
return true;
|
|
322
|
+
raw = readFileSync(readyPath(terminalId, env), 'utf8');
|
|
137
323
|
}
|
|
138
324
|
catch (err) {
|
|
139
325
|
return err.code === 'ENOENT' ? false : undefined;
|
|
140
326
|
}
|
|
327
|
+
const record = readLaunch(terminalId, env);
|
|
328
|
+
if (record === undefined)
|
|
329
|
+
return undefined;
|
|
330
|
+
if (record === null)
|
|
331
|
+
return true;
|
|
332
|
+
let mark = {};
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(raw);
|
|
335
|
+
if (parsed && typeof parsed === 'object')
|
|
336
|
+
mark = parsed;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* an unreadable mark marks nothing */
|
|
340
|
+
}
|
|
341
|
+
return record.next === undefined && mark.gen === record.gen && (mark.launch ?? null) === record.launch;
|
|
141
342
|
}
|
|
@@ -35,7 +35,7 @@ import { readHistory } from './history.js';
|
|
|
35
35
|
import { loginOf, readSession } from './session.js';
|
|
36
36
|
import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
|
|
37
37
|
import { MAX_OPEN_PTYS } from './pty.js';
|
|
38
|
-
import { claudeSessionSince, claudeTranscripts,
|
|
38
|
+
import { beginLaunch, claudeSessionSince, claudeTranscripts, currentLaunch, isReady, LAUNCH_ENV, launchState, markReady, restoreLaunch, } from './ready.js';
|
|
39
39
|
import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
|
|
40
40
|
import { noteInputTakeover } from './sizing.js';
|
|
41
41
|
import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, respawnPaneArgs, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
|
|
@@ -443,7 +443,10 @@ export class CodeTerminals {
|
|
|
443
443
|
adoptReadiness(record, configDir, startedAtMs) {
|
|
444
444
|
if (!(this.options.claudeSessionSince ?? claudeSessionSince)(record.cwd, configDir, startedAtMs))
|
|
445
445
|
return;
|
|
446
|
-
|
|
446
|
+
// The evidence is about the process the window runs now: its launch, if it has one, is marked.
|
|
447
|
+
const env = { PROHOST_HOME: this.options.home };
|
|
448
|
+
const launch = currentLaunch(record.terminalId, env);
|
|
449
|
+
if (launch !== undefined && markReady(record.terminalId, env, launch)) {
|
|
447
450
|
this.log(` ⚙ code thread ${record.codeThreadId.slice(0, 12)}: adopted past Claude Code's trust dialog (its session exists)`);
|
|
448
451
|
}
|
|
449
452
|
}
|
|
@@ -526,6 +529,12 @@ export class CodeTerminals {
|
|
|
526
529
|
const command = agentCommandLine(request.agent, { ...this.options.hookFiles, hasGoal: goal.length > 0, skipPermissions: skips });
|
|
527
530
|
// A shell thread's shell waits for its first viewer's size to settle (`sizing.ts`): one prompt.
|
|
528
531
|
const held = request.agent === 'shell';
|
|
532
|
+
// Its own launch (`ready.ts`): only this process's hooks mark it past its first-run screens. One
|
|
533
|
+
// that can't be recorded is a window no reply could ever be typed into: the start fails instead.
|
|
534
|
+
const launch = randomBytes(8).toString('hex');
|
|
535
|
+
if (request.agent === 'claude_code' && !beginLaunch(request.terminalId, launch, { PROHOST_HOME: this.options.home })) {
|
|
536
|
+
return fail("this Mac's code-host state can't be written ($PROHOST_HOME/code-host/state)");
|
|
537
|
+
}
|
|
529
538
|
const created = await this.tmux(newWindowArgs({
|
|
530
539
|
name,
|
|
531
540
|
cwd: checkout.path,
|
|
@@ -534,6 +543,7 @@ export class CodeTerminals {
|
|
|
534
543
|
PROHOST_HOME: this.options.home,
|
|
535
544
|
[TERMINAL_ID_ENV]: request.terminalId,
|
|
536
545
|
[CODE_THREAD_ID_ENV]: request.codeThreadId,
|
|
546
|
+
[LAUNCH_ENV]: launch,
|
|
537
547
|
...(goal ? { [GOAL_ENV]: goal } : {}),
|
|
538
548
|
},
|
|
539
549
|
command: held ? holdCommand(name, command) : command,
|
|
@@ -718,8 +728,9 @@ export class CodeTerminals {
|
|
|
718
728
|
const token = randomBytes(8).toString('hex');
|
|
719
729
|
const command = `tmux set-option -wu ${EXITED_OPTION} 2>/dev/null; tmux set-option -w ${RELAUNCH_OPTION} ${token} 2>/dev/null; ` +
|
|
720
730
|
agentCommandLine(agent, { ...this.options.hookFiles, hasGoal: false, skipPermissions: request.skipPermissions, resume });
|
|
721
|
-
// Like a new window: replies wait for the new Claude Code's first hook (`ready.ts`).
|
|
722
|
-
// the old process goes, so that hook's mark is never
|
|
731
|
+
// Like a new window: replies wait for the new Claude Code's first hook (`ready.ts`). Its launch is
|
|
732
|
+
// recorded (and the old mark dropped) before the old process goes, so that hook's mark is never
|
|
733
|
+
// wiped after it was written, and a late hook of the old process can't mark the new one.
|
|
723
734
|
// The switch once more, as late as it can be: nothing awaits between here and `respawn-pane`, so a
|
|
724
735
|
// `remote off` that landed during the looks above stops it before anything is claimed or touched.
|
|
725
736
|
if (request.skipPermissions && !this.canType())
|
|
@@ -736,9 +747,15 @@ export class CodeTerminals {
|
|
|
736
747
|
return false;
|
|
737
748
|
}
|
|
738
749
|
const before = this.panePids.get(record.terminalId);
|
|
739
|
-
const
|
|
740
|
-
if (
|
|
741
|
-
|
|
750
|
+
const previous = agent === 'claude_code' ? launchState(record.terminalId, env) : undefined;
|
|
751
|
+
if (previous && !beginLaunch(record.terminalId, token, env)) {
|
|
752
|
+
// Nothing was replaced, and nothing may be until the new launch can be told from the old one.
|
|
753
|
+
restoreLaunch(record.terminalId, previous, env);
|
|
754
|
+
this.log(` ! code thread ${short} not restarted yet: its launch could not be recorded`);
|
|
755
|
+
outcome.retry = true;
|
|
756
|
+
outcome.release = true;
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
742
759
|
// The restart's own `stop` is stamped now, before the new pane exists: any hook the new agent
|
|
743
760
|
// sends is strictly newer and wins under the server's host-event ordering, never this one.
|
|
744
761
|
const stopAt = this.now();
|
|
@@ -750,13 +767,14 @@ export class CodeTerminals {
|
|
|
750
767
|
PROHOST_HOME: this.options.home,
|
|
751
768
|
[TERMINAL_ID_ENV]: record.terminalId,
|
|
752
769
|
[CODE_THREAD_ID_ENV]: record.codeThreadId,
|
|
770
|
+
[LAUNCH_ENV]: token,
|
|
753
771
|
},
|
|
754
772
|
command,
|
|
755
773
|
}));
|
|
756
774
|
if (!respawned.ok && !respawned.uncertain) {
|
|
757
|
-
// Nothing was replaced: the old process (and
|
|
758
|
-
if (
|
|
759
|
-
|
|
775
|
+
// Nothing was replaced: the old process (its launch and readiness) stays, and it is worth sending again.
|
|
776
|
+
if (previous)
|
|
777
|
+
restoreLaunch(record.terminalId, previous, env);
|
|
760
778
|
this.log(` ! code thread ${short} not restarted: tmux said ${respawned.stderr.trim()}`);
|
|
761
779
|
outcome.retry = true;
|
|
762
780
|
outcome.release = true;
|
|
@@ -767,9 +785,9 @@ export class CodeTerminals {
|
|
|
767
785
|
// prompts are off.
|
|
768
786
|
const after = await this.panePidNow(record.terminalId);
|
|
769
787
|
if (after !== undefined && after === before) {
|
|
770
|
-
// The same process still runs: nothing was replaced. It keeps its readiness, and is sent again.
|
|
771
|
-
if (
|
|
772
|
-
|
|
788
|
+
// The same process still runs: nothing was replaced. It keeps its launch and readiness, and is sent again.
|
|
789
|
+
if (previous)
|
|
790
|
+
restoreLaunch(record.terminalId, previous, env);
|
|
773
791
|
this.log(` ! code thread ${short} not restarted: its window still runs the same process`);
|
|
774
792
|
outcome.retry = true;
|
|
775
793
|
outcome.release = true;
|
package/dist/host/api.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function callJson(options, path, body, method = 'POST') {
|
|
|
36
36
|
parsed = undefined;
|
|
37
37
|
}
|
|
38
38
|
const ok = response.status >= 200 && response.status < 300;
|
|
39
|
-
return { status: response.status, body: parsed, ...(ok ? {} : { error: text.slice(0,
|
|
39
|
+
return { status: response.status, body: parsed, ...(ok ? {} : { error: text.slice(0, 500) }) };
|
|
40
40
|
}
|
|
41
41
|
catch (err) {
|
|
42
42
|
if (controller.signal.aborted)
|
package/dist/host/command.d.ts
CHANGED
|
@@ -65,5 +65,10 @@ export declare function signInSpawn(command: string, args: string[], platform?:
|
|
|
65
65
|
}];
|
|
66
66
|
/** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
|
|
67
67
|
export declare function serviceStillLoaded(spec: ServiceSpec, serviceIo: InstallIo, pidAlive?: (pid: number) => boolean): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Remove every copy of the key a dedicated install made (the service home's, and on Windows the
|
|
70
|
+
* administrators' copy), each attempted on its own: the files that are still there, named.
|
|
71
|
+
*/
|
|
72
|
+
export declare function removeCopiedKeys(spec: ServiceSpec, serviceIo: InstallIo): string[];
|
|
68
73
|
export declare function runHost(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: HostIo): Promise<number>;
|
|
69
74
|
export {};
|
package/dist/host/command.js
CHANGED
|
@@ -529,6 +529,26 @@ function processAlive(pid) {
|
|
|
529
529
|
return e.code === 'EPERM';
|
|
530
530
|
}
|
|
531
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Remove every copy of the key a dedicated install made (the service home's, and on Windows the
|
|
534
|
+
* administrators' copy), each attempted on its own: the files that are still there, named.
|
|
535
|
+
*/
|
|
536
|
+
export function removeCopiedKeys(spec, serviceIo) {
|
|
537
|
+
const leftover = [];
|
|
538
|
+
const homeKey = (spec.platform === 'win32' ? path.win32 : path.posix).join(spec.home, 'host', 'computer.json');
|
|
539
|
+
if (rewriteHomeCredential(spec.platform, spec.home, undefined, serviceIo))
|
|
540
|
+
leftover.push(homeKey);
|
|
541
|
+
if (spec.platform === 'win32') {
|
|
542
|
+
const copy = windowsServiceCredentialPath(spec);
|
|
543
|
+
try {
|
|
544
|
+
(serviceIo.removeFile ?? ((f) => rmSync(f, { force: true })))(copy);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
leftover.push(copy);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return leftover;
|
|
551
|
+
}
|
|
532
552
|
/** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
|
|
533
553
|
function handedFromHome(flags, env, platform) {
|
|
534
554
|
return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
|
|
@@ -858,20 +878,16 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
858
878
|
})();
|
|
859
879
|
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
860
880
|
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
catch {
|
|
868
|
-
/* reported below with the rest */
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
err(serviceGone && keyGone
|
|
881
|
+
// (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
|
|
882
|
+
// although the task is gone — that copy is named by removeCopiedKeys below.)
|
|
883
|
+
const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
884
|
+
const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
|
|
885
|
+
err(serviceGone && leftover.length === 0
|
|
872
886
|
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
873
887
|
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
874
|
-
:
|
|
888
|
+
: serviceGone
|
|
889
|
+
? ` ✗ Partly undone: ${spec.label} is uninstalled, but its key is still in ${leftover.join(' and ')}: delete it yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`
|
|
890
|
+
: ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
875
891
|
return 1;
|
|
876
892
|
}
|
|
877
893
|
else {
|
package/dist/host/service.d.ts
CHANGED
|
@@ -117,6 +117,10 @@ export interface HostServiceResult {
|
|
|
117
117
|
* sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
|
|
118
118
|
*/
|
|
119
119
|
export declare function busyBackoffMs(waits: number): number;
|
|
120
|
+
/** A refused status is logged again at most this often while refusals continue. */
|
|
121
|
+
export declare const STATUS_FAILURE_LOG_MS: number;
|
|
122
|
+
/** A server's error text with anything shaped like a key or token blanked out. */
|
|
123
|
+
export declare function redactSecrets(text: string): string;
|
|
120
124
|
/** The CLI's own entry file: when it changes on disk, an update was installed. */
|
|
121
125
|
export declare function entrypointStamp(entry?: string): () => boolean;
|
|
122
126
|
export declare function runHostService(options: HostServiceOptions): Promise<HostServiceResult>;
|
package/dist/host/service.js
CHANGED
|
@@ -89,6 +89,16 @@ const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
|
89
89
|
export function busyBackoffMs(waits) {
|
|
90
90
|
return Math.min(30_000 * 2 ** waits, 120_000);
|
|
91
91
|
}
|
|
92
|
+
/** A refused status is logged again at most this often while refusals continue. */
|
|
93
|
+
export const STATUS_FAILURE_LOG_MS = 10 * 60_000;
|
|
94
|
+
/** A server's error text with anything shaped like a key or token blanked out. */
|
|
95
|
+
export function redactSecrets(text) {
|
|
96
|
+
return text
|
|
97
|
+
.replace(/\b(pk|sk|rk|whsec)_[A-Za-z0-9_-]{8,}/g, '$1_…')
|
|
98
|
+
.replace(/\bsk-ant-[A-Za-z0-9_-]{8,}/g, 'sk-ant-…')
|
|
99
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{8,}/g, 'gh…')
|
|
100
|
+
.replace(/\b(Bearer|token)\s+[A-Za-z0-9._-]{12,}/gi, '$1 …');
|
|
101
|
+
}
|
|
92
102
|
function timestamp() {
|
|
93
103
|
return new Date().toISOString().slice(11, 19);
|
|
94
104
|
}
|
|
@@ -260,12 +270,15 @@ async function serveHost(options) {
|
|
|
260
270
|
// ── What is running ──────────────────────────────────────────────────────
|
|
261
271
|
function runningEntries() {
|
|
262
272
|
const runs = [...active.values()].map((run) => ({ ...run.entry, peak_mem_mb: peak.peakMb(run.entry.run_id) }));
|
|
273
|
+
// A terminal session is no employee's: no employee id (its runtime instead), and no start time
|
|
274
|
+
// this host knows (`started_at: null`, never '' — the server reads both as typed values).
|
|
263
275
|
const terms = (codeHost?.liveTerminals?.() ?? []).map((t) => ({
|
|
264
276
|
run_id: t.terminalId,
|
|
265
|
-
agent_id:
|
|
277
|
+
agent_id: null,
|
|
278
|
+
runtime: t.agent,
|
|
266
279
|
conversation_key: `code_thread:${t.codeThreadId}`,
|
|
267
280
|
kind: 'terminal',
|
|
268
|
-
started_at:
|
|
281
|
+
started_at: null,
|
|
269
282
|
}));
|
|
270
283
|
return [...runs, ...terms];
|
|
271
284
|
}
|
|
@@ -326,6 +339,8 @@ async function serveHost(options) {
|
|
|
326
339
|
let statusTimer;
|
|
327
340
|
let statusInFlight;
|
|
328
341
|
let statusFailures = 0;
|
|
342
|
+
/** When a refused status was last logged: a repeat is logged again (with its count) at most this often. */
|
|
343
|
+
let statusFailureLoggedAt = 0;
|
|
329
344
|
function statusChanged() {
|
|
330
345
|
writeLocalState();
|
|
331
346
|
if (statusTimer)
|
|
@@ -374,8 +389,14 @@ async function serveHost(options) {
|
|
|
374
389
|
return;
|
|
375
390
|
}
|
|
376
391
|
statusFailures += 1;
|
|
377
|
-
|
|
378
|
-
|
|
392
|
+
// The server's reason (a 422 names the field), at the first refusal and then every 10 minutes
|
|
393
|
+
// while it lasts, with how many there were — never only once and then silence.
|
|
394
|
+
if (statusFailures === 1 || now() - statusFailureLoggedAt >= STATUS_FAILURE_LOG_MS) {
|
|
395
|
+
statusFailureLoggedAt = now();
|
|
396
|
+
const why = redactSecrets(response.error ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
397
|
+
log(`${timestamp()} ! status not accepted (${response.status || 'no answer'}${statusFailures > 1 ? `, ${statusFailures} times in a row` : ''})` +
|
|
398
|
+
(why ? `: ${why}` : ''));
|
|
399
|
+
}
|
|
379
400
|
})().finally(() => {
|
|
380
401
|
statusInFlight = undefined;
|
|
381
402
|
});
|
package/dist/host/status.d.ts
CHANGED
|
@@ -34,13 +34,17 @@ export declare function parseConfig(body: Record<string, unknown> | undefined, p
|
|
|
34
34
|
/** One run going on this computer. */
|
|
35
35
|
export interface RunningEntry {
|
|
36
36
|
run_id: string;
|
|
37
|
-
|
|
37
|
+
/** The employee's id (a UUID); null for a terminal session, which is no employee's. */
|
|
38
|
+
agent_id: string | null;
|
|
39
|
+
/** A terminal session's agent runtime (`claude_code`, `codex`, …). */
|
|
40
|
+
runtime?: string;
|
|
38
41
|
agent_name?: string;
|
|
39
42
|
agent_avatar_url?: string;
|
|
40
43
|
title?: string;
|
|
41
44
|
conversation_key?: string;
|
|
42
45
|
kind: 'employee' | 'terminal';
|
|
43
|
-
|
|
46
|
+
/** ISO-8601, or null when it isn't known (a terminal session). */
|
|
47
|
+
started_at: string | null;
|
|
44
48
|
peak_mem_mb?: number;
|
|
45
49
|
started_by_user_id?: string;
|
|
46
50
|
}
|
package/dist/host/status.js
CHANGED
|
@@ -69,16 +69,32 @@ export function hostStatsBody(stats) {
|
|
|
69
69
|
battery_pct: stats?.batteryPct ?? null,
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
function clip(value, max) {
|
|
73
|
+
if (value === null || value === undefined)
|
|
74
|
+
return null;
|
|
75
|
+
// By code point, as the server counts (`len()` of a Python str): never half a surrogate pair,
|
|
76
|
+
// which it would refuse as invalid Unicode.
|
|
77
|
+
const points = Array.from(value);
|
|
78
|
+
return points.length > max ? points.slice(0, max).join('') : value;
|
|
79
|
+
}
|
|
72
80
|
/** A login as the status body names it: no email, no directory. */
|
|
73
81
|
export function loginBody(login, conversations) {
|
|
74
82
|
return {
|
|
75
83
|
account_key: login.account_key,
|
|
76
|
-
label: login.label,
|
|
77
|
-
runtime: login.runtime,
|
|
78
|
-
subscription_type: login.subscription_type,
|
|
79
|
-
plan_label: login.plan_label,
|
|
84
|
+
label: clip(login.label, 120) || 'login',
|
|
85
|
+
runtime: clip(login.runtime, 40),
|
|
86
|
+
subscription_type: clip(login.subscription_type, 40),
|
|
87
|
+
plan_label: clip(login.plan_label, 80),
|
|
80
88
|
auth_state: login.auth_state,
|
|
81
|
-
quota_windows: login.quota_windows
|
|
89
|
+
quota_windows: login.quota_windows
|
|
90
|
+
.filter((w) => typeof w.kind === 'string' && w.kind.length > 0)
|
|
91
|
+
.slice(0, 16)
|
|
92
|
+
.map((w) => ({
|
|
93
|
+
kind: clip(w.kind, 40),
|
|
94
|
+
used_percent: Math.min(100, Math.max(0, w.used_percent)),
|
|
95
|
+
resets_at: w.resets_at,
|
|
96
|
+
...(w.model_label ? { model_label: clip(w.model_label, 40) } : {}),
|
|
97
|
+
})),
|
|
82
98
|
conversations,
|
|
83
99
|
};
|
|
84
100
|
}
|
|
@@ -89,11 +105,13 @@ export function buildStatusBody(inputs) {
|
|
|
89
105
|
slots: { ...inputs.slots },
|
|
90
106
|
logins: inputs.logins.map((login) => loginBody(login, inputs.conversationsPerLogin.get(login.account_key) ?? 0)),
|
|
91
107
|
running: inputs.running.map((entry) => ({
|
|
92
|
-
run_id: entry.run_id,
|
|
108
|
+
run_id: clip(entry.run_id, 128),
|
|
93
109
|
agent_id: entry.agent_id,
|
|
94
|
-
|
|
110
|
+
...(entry.runtime ? { runtime: clip(entry.runtime, 40) } : {}),
|
|
111
|
+
conversation_key: clip(entry.conversation_key, 256),
|
|
95
112
|
kind: entry.kind,
|
|
96
|
-
|
|
113
|
+
// Never '' (the server reads it as a date): a time, or null.
|
|
114
|
+
started_at: entry.started_at || null,
|
|
97
115
|
peak_mem_mb: entry.peak_mem_mb ?? null,
|
|
98
116
|
...(entry.started_by_user_id ? { started_by_user_id: entry.started_by_user_id } : {}),
|
|
99
117
|
})),
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.16.
|
|
10
|
+
export declare const CLI_VERSION = "0.16.7";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.16.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.16.7";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.16.
|
|
10
|
+
export const CLI_VERSION = '0.16.7';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|