@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 +46 -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 +119 -31
- package/dist/host/install.js +11 -3
- 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/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
|
@@ -520,6 +520,21 @@ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
|
|
|
520
520
|
return true;
|
|
521
521
|
}
|
|
522
522
|
}
|
|
523
|
+
/** How long a give-back waits for an uninstalled service to finish stopping. */
|
|
524
|
+
const GIVE_BACK_WAIT_MS = 30_000;
|
|
525
|
+
/** Whether `spec`'s service is gone, waiting up to {@link GIVE_BACK_WAIT_MS} for it to stop. */
|
|
526
|
+
function waitUntilGone(spec, serviceIo) {
|
|
527
|
+
const now = serviceIo.now ?? Date.now;
|
|
528
|
+
const sleep = serviceIo.sleep ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
|
|
529
|
+
const deadline = now() + GIVE_BACK_WAIT_MS;
|
|
530
|
+
for (;;) {
|
|
531
|
+
if (!serviceStillLoaded(spec, serviceIo))
|
|
532
|
+
return true;
|
|
533
|
+
if (now() >= deadline)
|
|
534
|
+
return false;
|
|
535
|
+
sleep(500);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
523
538
|
function processAlive(pid) {
|
|
524
539
|
try {
|
|
525
540
|
process.kill(pid, 0);
|
|
@@ -529,6 +544,26 @@ function processAlive(pid) {
|
|
|
529
544
|
return e.code === 'EPERM';
|
|
530
545
|
}
|
|
531
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Remove every copy of the key a dedicated install made (the service home's, and on Windows the
|
|
549
|
+
* administrators' copy), each attempted on its own: the files that are still there, named.
|
|
550
|
+
*/
|
|
551
|
+
export function removeCopiedKeys(spec, serviceIo) {
|
|
552
|
+
const leftover = [];
|
|
553
|
+
const homeKey = (spec.platform === 'win32' ? path.win32 : path.posix).join(spec.home, 'host', 'computer.json');
|
|
554
|
+
if (rewriteHomeCredential(spec.platform, spec.home, undefined, serviceIo))
|
|
555
|
+
leftover.push(homeKey);
|
|
556
|
+
if (spec.platform === 'win32') {
|
|
557
|
+
const copy = windowsServiceCredentialPath(spec);
|
|
558
|
+
try {
|
|
559
|
+
(serviceIo.removeFile ?? ((f) => rmSync(f, { force: true })))(copy);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
leftover.push(copy);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return leftover;
|
|
566
|
+
}
|
|
532
567
|
/** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
|
|
533
568
|
function handedFromHome(flags, env, platform) {
|
|
534
569
|
return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
|
|
@@ -746,7 +781,18 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
746
781
|
for (const o of shown)
|
|
747
782
|
(o.ok ? out : err)(o.message);
|
|
748
783
|
let ok = shown.every((o) => o.ok);
|
|
749
|
-
|
|
784
|
+
// The key goes back at once: the service's definition is gone, so a later run could never
|
|
785
|
+
// find it again, and a key left only in its home (an interrupted wait, say) would be stranded.
|
|
786
|
+
// Uninstall ignores a stop that failed (`launchctl bootout`, `systemctl disable --now`), so the
|
|
787
|
+
// service may still be draining its runs on the old copy: that is waited for (bounded) only
|
|
788
|
+
// afterwards, to say so.
|
|
789
|
+
const handedSpec = note && handedTo ? { ...spec, label: note.handed_to.label, home: handedTo.home } : undefined;
|
|
790
|
+
// Its definition removed (its task deleted), whatever else failed after: on Windows a copy of
|
|
791
|
+
// the key that couldn't be deleted makes uninstall report not ok, yet the task is gone, and
|
|
792
|
+
// no later run could find the key again. Those failures still fail this command (`ok`).
|
|
793
|
+
const definitionGone = (o) => o.ok || o.message.includes('✓ Removed');
|
|
794
|
+
const handedOutcome = note ? outcomes.find((o) => o.label === note.handed_to.label && definitionGone(o)) : undefined;
|
|
795
|
+
if (note && fromHome && handedOutcome) {
|
|
750
796
|
const key = handedKeyLive;
|
|
751
797
|
if (key && key.computer_id === note.computer_id && handedTo) {
|
|
752
798
|
const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
|
|
@@ -757,17 +803,28 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
757
803
|
else {
|
|
758
804
|
// On Windows the (administrators') service home keeps its copy: a later dedicated
|
|
759
805
|
// install finds that home and writes nothing into it, so it must still hold the key.
|
|
760
|
-
|
|
761
|
-
|
|
806
|
+
const cleared = platform === 'win32' || clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
|
|
807
|
+
if (!cleared) {
|
|
808
|
+
// Back here, and still there: the same key in two homes. Not done until that copy goes.
|
|
809
|
+
ok = false;
|
|
810
|
+
err(` ✗ The key is back in ${fromHome} but also still in ${handedTo.home}: delete that copy before using either.`);
|
|
811
|
+
}
|
|
762
812
|
out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
|
|
763
813
|
if (platform === 'win32')
|
|
764
814
|
out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
|
|
765
815
|
}
|
|
766
816
|
}
|
|
767
817
|
else {
|
|
768
|
-
clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err)
|
|
818
|
+
if (!clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err))
|
|
819
|
+
ok = false;
|
|
769
820
|
out(` ${fromHome} no longer points at ${note.handed_to.label}. Its key was not found: add the computer again with prohost host pair, or remove it on its page in ProhostAI.`);
|
|
770
821
|
}
|
|
822
|
+
// Only now (the key is where it belongs): whether the service really stopped, bounded.
|
|
823
|
+
if (handedSpec && !waitUntilGone(handedSpec, io.serviceIo ?? {})) {
|
|
824
|
+
ok = false;
|
|
825
|
+
err(` ! ${note.handed_to.label} was still stopping after ${GIVE_BACK_WAIT_MS / 1000}s: wait until it has exited ` +
|
|
826
|
+
`(${platform === 'darwin' ? `sudo launchctl print system/${note.handed_to.label}` : platform === 'win32' ? 'Task Manager' : `systemctl status ${systemdUnitOf(handedSpec)}`}) before running this computer from ${fromHome}.`);
|
|
827
|
+
}
|
|
771
828
|
}
|
|
772
829
|
return ok ? 0 : 1;
|
|
773
830
|
}
|
|
@@ -795,8 +852,17 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
795
852
|
(env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
|
|
796
853
|
// The key this install takes over, read before it runs: only a copy it really made is retired.
|
|
797
854
|
const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
|
|
798
|
-
//
|
|
799
|
-
const
|
|
855
|
+
// The key the service home already holds, read before this install (then it copies none in).
|
|
856
|
+
const serviceBefore = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) : null;
|
|
857
|
+
// Another computer's: installing would restart that computer under this label and leave this
|
|
858
|
+
// one's key where it is, reported as a takeover. Refused before anything is installed.
|
|
859
|
+
if (handedFrom && serviceBefore && serviceBefore.computer_id !== handedFrom.computer_id) {
|
|
860
|
+
err(`✗ ${home} already holds the key of another computer ("${serviceBefore.name}", ${serviceBefore.computer_id}), not ` +
|
|
861
|
+
`${adoptFrom}'s ("${handedFrom.name}"): nothing was installed. Remove that computer first ` +
|
|
862
|
+
`(sudo prohost host unpair --label ${spec.label}), then install again.`);
|
|
863
|
+
return 1;
|
|
864
|
+
}
|
|
865
|
+
const serviceHadKey = serviceBefore !== null;
|
|
800
866
|
const outcome = installService(spec, io.serviceIo, {
|
|
801
867
|
...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
|
|
802
868
|
...(dedicated ? { adoptFrom } : {}),
|
|
@@ -808,6 +874,38 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
808
874
|
? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
|
|
809
875
|
: ` This home (${home}) has no computer yet: run prohost host pair.`);
|
|
810
876
|
}
|
|
877
|
+
// Undo this install (it copied a key in): the service uninstalled, every copy of the key it made
|
|
878
|
+
// removed, and the original home keeps its key. Returns the exit code.
|
|
879
|
+
const undoInstall = (options = { removeKeys: true }) => {
|
|
880
|
+
const undone = (() => {
|
|
881
|
+
try {
|
|
882
|
+
return uninstallService(spec, io.serviceIo);
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
889
|
+
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
890
|
+
// (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
|
|
891
|
+
// although the task is gone — that copy is named by removeCopiedKeys below.)
|
|
892
|
+
const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
893
|
+
if (!options.removeKeys) {
|
|
894
|
+
// Which file this install copied can't be told from one it didn't: no credential is deleted.
|
|
895
|
+
err(serviceGone
|
|
896
|
+
? ` ✗ ${spec.label} is uninstalled. Check ${path.join(home, 'host', 'computer.json')} before installing again; ${adoptFrom} keeps its key.`
|
|
897
|
+
: ` ✗ Could not uninstall ${spec.label}: remove it (sudo prohost host uninstall --label ${spec.label}), then check ${path.join(home, 'host', 'computer.json')}.`);
|
|
898
|
+
return 1;
|
|
899
|
+
}
|
|
900
|
+
const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
|
|
901
|
+
err(serviceGone && leftover.length === 0
|
|
902
|
+
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
903
|
+
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
904
|
+
: serviceGone
|
|
905
|
+
? ` ✗ 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.`
|
|
906
|
+
: ` ✗ 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.`);
|
|
907
|
+
return 1;
|
|
908
|
+
};
|
|
811
909
|
if (outcome.ok && dedicated && handedFrom) {
|
|
812
910
|
// One key, one holder: the home it came from keeps a note of where the computer went, never
|
|
813
911
|
// the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
|
|
@@ -848,31 +946,7 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
848
946
|
return 1;
|
|
849
947
|
}
|
|
850
948
|
else if (!serviceHadKey) {
|
|
851
|
-
|
|
852
|
-
try {
|
|
853
|
-
return uninstallService(spec, io.serviceIo);
|
|
854
|
-
}
|
|
855
|
-
catch (e) {
|
|
856
|
-
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
857
|
-
}
|
|
858
|
-
})();
|
|
859
|
-
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
860
|
-
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
861
|
-
const serviceGone = undone.ok && undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
862
|
-
const keyGone = serviceGone && !rewriteHomeCredential(platform, home, undefined, io.serviceIo);
|
|
863
|
-
if (keyGone && platform === 'win32') {
|
|
864
|
-
try {
|
|
865
|
-
(io.serviceIo?.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath(spec));
|
|
866
|
-
}
|
|
867
|
-
catch {
|
|
868
|
-
/* reported below with the rest */
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
err(serviceGone && keyGone
|
|
872
|
-
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
873
|
-
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
874
|
-
: ` ✗ 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
|
-
return 1;
|
|
949
|
+
return undoInstall();
|
|
876
950
|
}
|
|
877
951
|
else {
|
|
878
952
|
err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
|
|
@@ -885,6 +959,20 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
885
959
|
out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
|
|
886
960
|
}
|
|
887
961
|
}
|
|
962
|
+
else if (!serviceHadKey) {
|
|
963
|
+
// The copy this install made isn't there afterwards (or can't be read, or is another
|
|
964
|
+
// computer's): no takeover happened, and the service must not run on whatever it holds.
|
|
965
|
+
// Uninstalled; its files are left for a person to judge.
|
|
966
|
+
err(` ✗ ${spec.label} was installed, but ${home} does not hold ${adoptFrom}'s key afterwards ` +
|
|
967
|
+
(serviceHas ? `(it holds ${serviceHas.computer_id}).` : "(it can't be read)."));
|
|
968
|
+
return undoInstall({ removeKeys: false });
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
// It held this computer's key before the install and can't be read now: never reported done.
|
|
972
|
+
err(` ✗ ${home} held this computer's key before this install, but it can't be read now: check ` +
|
|
973
|
+
`${path.join(home, 'host', 'computer.json')}, and never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
888
976
|
}
|
|
889
977
|
if (outcome.ok && dedicated) {
|
|
890
978
|
out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
|
package/dist/host/install.js
CHANGED
|
@@ -1048,9 +1048,17 @@ export function uninstallService(spec, io = {}) {
|
|
|
1048
1048
|
const out = run('schtasks', ['/Delete', '/TN', task, '/F']);
|
|
1049
1049
|
if (out.status === 0)
|
|
1050
1050
|
removed.push(task);
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1051
|
+
// A delete that failed while the task is still there is a failure, never "nothing installed":
|
|
1052
|
+
// the task can start again on its key. Its files stay, so a later uninstall still finds it.
|
|
1053
|
+
const stillThere = out.status !== 0 && run('schtasks', ['/Query', '/TN', task]).status === 0;
|
|
1054
|
+
if (stillThere) {
|
|
1055
|
+
notes.push(`could not delete the task ${task} (${(out.stderr || out.stdout || `exit ${out.status}`).trim()}): it is still installed`);
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
for (const file of [serviceFilePath(spec), windowsWrapperPath(spec)]) {
|
|
1059
|
+
if (exists(file))
|
|
1060
|
+
remove(file);
|
|
1061
|
+
}
|
|
1054
1062
|
}
|
|
1055
1063
|
// The administrators' copy of its key goes with the service: a reinstall never finds an old one.
|
|
1056
1064
|
const copy = windowsServiceCredentialPath(spec);
|
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>;
|