@prohost/cli 0.16.5 → 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.
@@ -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
- * Mark the terminal's Claude Code past its trust dialog (a hook ran). Atomic (`writePrivateJson`).
23
- * Returns whether it is marked afterwards; `false` means the write failed, and the caller says so.
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
- if (isReady(terminalId, env) === true)
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
- writePrivateJson(readyPath(terminalId, env), Date.now());
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 was replaced (a restart), and the new one is past its
40
- * dialog only once its own first hook runs. Returns whether no mark is left.
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
- /** `true` past the dialog, `false` not yet, `undefined` when the state can't be read. */
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
- statSync(readyPath(terminalId, env));
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, clearReady, isReady, markReady } from './ready.js';
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
- if (markReady(record.terminalId, { PROHOST_HOME: this.options.home })) {
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`). Dropped before
722
- // the old process goes, so that hook's mark is never wiped after it was written.
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 wasReady = agent === 'claude_code' && this.isReady(record.terminalId) === true;
740
- if (agent === 'claude_code')
741
- clearReady(record.terminalId, env);
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 its readiness) stays, and it is worth sending again.
758
- if (wasReady)
759
- markReady(record.terminalId, env);
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 (wasReady)
772
- markReady(record.terminalId, env);
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, 300) }) };
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)
@@ -11,7 +11,7 @@
11
11
  * prohost host pause | resume
12
12
  * prohost host migrate [--from <home>]… [--home <dir,…>] [--keep-services] [--dry-run] [--json]
13
13
  */
14
- import type { InstallIo } from './install.js';
14
+ import type { InstallIo, ServiceSpec } from './install.js';
15
15
  import type { LoginInfo } from './logins.js';
16
16
  import { runMigrate } from './migrate.js';
17
17
  type Flags = Record<string, string | boolean>;
@@ -63,5 +63,12 @@ export declare function statusJson(env: NodeJS.ProcessEnv, now?: number, options
63
63
  export declare function signInSpawn(command: string, args: string[], platform?: NodeJS.Platform): [string, string[], {
64
64
  windowsVerbatimArguments?: boolean;
65
65
  }];
66
+ /** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
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[];
66
73
  export declare function runHost(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: HostIo): Promise<number>;
67
74
  export {};
@@ -29,7 +29,7 @@ import { addAccount, isValidLabel, loadAccounts, parseAccountRuntime, accountEnv
29
29
  import { revokeComputer } from './api.js';
30
30
  import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, handoffNotice, handoffOf, hostLockPath, SERVICE_LABEL, readHandoff, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
31
31
  import { journalPath, readEvents } from './events.js';
32
- import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
32
+ import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, systemdUnitOf, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
33
33
  import { LegacyHarnesses } from './legacy.js';
34
34
  import { detectLogins } from './logins.js';
35
35
  import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
@@ -490,6 +490,65 @@ function serviceCredential(platform, home, user, serviceIo, label) {
490
490
  return null;
491
491
  }
492
492
  }
493
+ /** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
494
+ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
495
+ const run = serviceIo.run ?? defaultServiceRunner;
496
+ try {
497
+ if (spec.platform === 'darwin')
498
+ return run('launchctl', ['print', `system/${spec.label}`]).status === 0;
499
+ if (spec.platform === 'win32') {
500
+ if (run('schtasks', ['/Query', '/TN', windowsTaskOf(spec)]).status === 0)
501
+ return true;
502
+ // Deleting a task does not stop what it started: the host's own lock names a live process.
503
+ // Only "no such file" means no lock; any other read error is an unknown, so still there (fail closed).
504
+ const lock = (serviceIo.readFile ?? ((f) => {
505
+ try {
506
+ return readFileSync(f, 'utf8');
507
+ }
508
+ catch (e) {
509
+ if (e.code === 'ENOENT')
510
+ return undefined;
511
+ throw e;
512
+ }
513
+ }))(path.win32.join(spec.home, 'host', 'service.lock'));
514
+ const pid = lock ? JSON.parse(lock).pid : undefined;
515
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pidAlive(pid);
516
+ }
517
+ return run('systemctl', ['is-active', '--quiet', systemdUnitOf(spec)]).status === 0;
518
+ }
519
+ catch {
520
+ return true;
521
+ }
522
+ }
523
+ function processAlive(pid) {
524
+ try {
525
+ process.kill(pid, 0);
526
+ return true;
527
+ }
528
+ catch (e) {
529
+ return e.code === 'EPERM';
530
+ }
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
+ }
493
552
  /** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
494
553
  function handedFromHome(flags, env, platform) {
495
554
  return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
@@ -756,6 +815,8 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
756
815
  (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
757
816
  // The key this install takes over, read before it runs: only a copy it really made is retired.
758
817
  const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
818
+ // Whether the service home already held a key before this install (then it copies none).
819
+ const serviceHadKey = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) !== null : false;
759
820
  const outcome = installService(spec, io.serviceIo, {
760
821
  ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
761
822
  ...(dedicated ? { adoptFrom } : {}),
@@ -790,8 +851,51 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
790
851
  });
791
852
  const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
792
853
  if (problem) {
793
- err(` ✗ The service has the computer, but its key is still in ${adoptFrom} too (${problem}). ` +
794
- `Delete ${path.join(adoptFrom, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from that home: it would remove the computer.`);
854
+ // The note is part of the takeover: without it the key would live in both homes, and an
855
+ // `unpair` here would remove the computer under the service. What this install did is undone.
856
+ err(` ✗ Could not leave the hand-off note in ${adoptFrom} (${problem}).`);
857
+ // What the original home holds now decides the undo (on Windows a rename can land before a
858
+ // later check fails): its key still there → undo; the note there after all → handed over;
859
+ // neither → the service keeps the only key.
860
+ const sourceKey = tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom });
861
+ const sourceNote = readHandoff({ ...env, PROHOST_HOME: adoptFrom });
862
+ const handedAnyway = !sourceKey && sourceNote?.handed_to.label === spec.label && sourceNote?.computer_id === handedFrom.computer_id;
863
+ if (handedAnyway) {
864
+ err(` ! The note is in place after all: the computer's key is the service's alone. Check ${path.join(adoptFrom, 'host', 'computer.json')}.`);
865
+ }
866
+ else if (sourceKey?.computer_id !== handedFrom.computer_id) {
867
+ err(` ✗ ${adoptFrom} no longer holds this computer's key, so ${spec.label} keeps it: check ${path.join(adoptFrom, 'host', 'computer.json')}, never delete ${home}.`);
868
+ return 1;
869
+ }
870
+ else if (!serviceHadKey) {
871
+ const undone = (() => {
872
+ try {
873
+ return uninstallService(spec, io.serviceIo);
874
+ }
875
+ catch (e) {
876
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
877
+ }
878
+ })();
879
+ // Removed and no longer loaded, not just "nothing failed": a Windows task that would not
880
+ // stop removes nothing, and a launchd / systemd service whose stop failed may still run.
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
886
+ ? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
887
+ (platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
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.`);
891
+ return 1;
892
+ }
893
+ else {
894
+ err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
895
+ `Fix the problem above, then install again.`);
896
+ }
897
+ if (!handedAnyway)
898
+ return 1;
795
899
  }
796
900
  else {
797
901
  out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
@@ -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>;
@@ -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: t.agent,
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
- if (statusFailures === 1)
378
- log(`${timestamp()} ! status not accepted (${response.status || response.error})`);
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
  });
@@ -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
- agent_id: string;
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
- started_at: string;
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
  }