@prohost/cli 0.16.10 → 0.16.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.12
10
+
11
+ - **A computer says what its terminal sessions may start in.** `prohost host run` hosted terminal
12
+ sessions with no repository checkouts and reported none of its opened folders, so a phone or the
13
+ web offered no folder to pick and every repository start failed on the Mac. Its status post now
14
+ carries `code_host` (its checkouts, the folders this Mac has opened, and its home) while it hosts
15
+ terminal sessions, and its terminal host takes the `--repos` its adopted homes ran with.
16
+
17
+ ## 0.16.11
18
+
19
+ - **An end that can't check what is left of a terminal is retried, never said done.** When a retry
20
+ of `code_thread.end_requested` (after a kill whose tmux answer was lost) couldn't ask `ps` which
21
+ saved panes still belong to the terminal, it reported the end done with nothing reaped, so a
22
+ process group that outlived its window could keep running. Now what was just closed is reaped,
23
+ the saved panes stay in the unfinished `.ended` mark, and the end is asked for again until `ps`
24
+ answers.
25
+
9
26
  ## 0.16.10
10
27
 
11
28
  - **A Code thread's terminal can be ended from ProhostAI.** `code_thread.end_requested` closes the
@@ -9,10 +9,10 @@
9
9
  /** Whether the lock at `file` was left by a holder that died. */
10
10
  type StaleJudge = (file: string, nowMs: number) => boolean;
11
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.
12
+ * When process `pid` started, in whole seconds, or `undefined` if that can't be told (within
13
+ * `timeoutMs`). From its elapsed time (`ps -o etime`), never a printed date: no time zone to get wrong.
14
14
  */
15
- export declare function processStartSec(pid: number, nowMs?: number): number | undefined;
15
+ export declare function processStartSec(pid: number, nowMs?: number, timeoutMs?: number): number | undefined;
16
16
  /**
17
17
  * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
18
18
  * judged again. A file that is no longer stale is another process's fresh lock (it took over the
@@ -15,16 +15,17 @@ const olderThan = (staleMs) => (file, nowMs) => nowMs - statSync(file).mtimeMs >
15
15
  let ownStart;
16
16
  /**
17
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.
18
+ * against, so a clock correction since can't set them apart); kept once known. `0` (none) when `ps`
19
+ * can't tell within `timeoutMs` (asked again next time): such a lock is then judged by its holder's
20
+ * pid and the lock's age ({@link holderGone}).
20
21
  */
21
- function ownStartSec() {
22
- ownStart ??= processStartSec(process.pid) ?? 0;
23
- return ownStart;
22
+ function ownStartSec(timeoutMs) {
23
+ ownStart ??= processStartSec(process.pid, Date.now(), timeoutMs);
24
+ return ownStart ?? 0;
24
25
  }
25
26
  /** `<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)}`;
27
+ function newToken(withStart, timeoutMs) {
28
+ return `${process.pid}.${withStart ? ownStartSec(timeoutMs) : 0}.${Math.random().toString(16).slice(2)}`;
28
29
  }
29
30
  /** The holder named in the lock at `file`, or `undefined` if its token can't be read. */
30
31
  function holderOf(file) {
@@ -66,14 +67,14 @@ function elapsedSec(text) {
66
67
  return ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
67
68
  }
68
69
  /**
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.
70
+ * When process `pid` started, in whole seconds, or `undefined` if that can't be told (within
71
+ * `timeoutMs`). From its elapsed time (`ps -o etime`), never a printed date: no time zone to get wrong.
71
72
  */
72
- export function processStartSec(pid, nowMs = Date.now()) {
73
+ export function processStartSec(pid, nowMs = Date.now(), timeoutMs = 1_000) {
73
74
  try {
74
75
  const out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], {
75
76
  encoding: 'utf8',
76
- timeout: 1_000,
77
+ timeout: timeoutMs,
77
78
  stdio: ['ignore', 'pipe', 'ignore'],
78
79
  });
79
80
  const elapsed = elapsedSec(out);
@@ -88,8 +89,9 @@ const bootedAtMs = () => Date.now() - os.uptime() * 1000;
88
89
  /**
89
90
  * Stale only once its holder is gone. A holder that still runs (suspended, say, while the Mac slept)
90
91
  * 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.
92
+ * a lock past `staleMs`, a pid that now belongs to another process: it started at another time than
93
+ * the token says, or, for a token that doesn't say (an older build's, or `ps` couldn't tell its
94
+ * writer), after the lock was made. A lock whose token can't be read is judged by age.
93
95
  */
94
96
  const holderGone = (staleMs, startOf) => (file, nowMs) => {
95
97
  const { mtimeMs } = statSync(file);
@@ -100,11 +102,14 @@ const holderGone = (staleMs, startOf) => (file, nowMs) => {
100
102
  return nowMs - mtimeMs > staleMs;
101
103
  if (!running(holder.pid))
102
104
  return true;
103
- if (nowMs - mtimeMs <= staleMs || holder.startSec === undefined)
105
+ if (nowMs - mtimeMs <= staleMs)
104
106
  return false;
105
- // Old, and its pid runs: the same process only if it started when the holder did.
107
+ // Old, and its pid runs: the same process only if it started when the holder did. A process that
108
+ // started after the lock was made can't have made it.
106
109
  const started = startOf(holder.pid);
107
- return started !== undefined && Math.abs(started - holder.startSec) > 2;
110
+ if (started === undefined)
111
+ return false;
112
+ return holder.startSec !== undefined ? Math.abs(started - holder.startSec) > 2 : started > mtimeMs / 1000 + 2;
108
113
  };
109
114
  /**
110
115
  * Remove a stale lock without ever removing a live one: it is moved aside atomically first, then
@@ -179,11 +184,19 @@ export function withFileLock(lock, work, options) {
179
184
  ensurePrivateDir(path.dirname(lock));
180
185
  const now = options.now ?? Date.now;
181
186
  const deadline = now() + options.waitMs;
182
- const stale = options.liveHolders
183
- ? holderGone(options.staleMs, options.processStart ?? processStartSec)
184
- : olderThan(options.staleMs);
187
+ // A holder's start is asked once per attempt (`ps` is synchronous: a waiter polling an old lock must
188
+ // not run it on every look), and never for longer than the attempt may still wait.
189
+ const probeTimeoutMs = () => Math.min(1_000, Math.max(50, deadline - now()));
190
+ const probed = new Map();
191
+ const startOf = (pid) => {
192
+ if (!probed.has(pid)) {
193
+ probed.set(pid, options.processStart ? options.processStart(pid) : processStartSec(pid, Date.now(), probeTimeoutMs()));
194
+ }
195
+ return probed.get(pid);
196
+ };
197
+ const stale = options.liveHolders ? holderGone(options.staleMs, startOf) : olderThan(options.staleMs);
185
198
  // Released only while the file is still this one: never a lock another process holds now.
186
- const token = newToken(options.liveHolders === true);
199
+ const token = newToken(options.liveHolders === true, probeTimeoutMs());
187
200
  // The token is written first, privately; the lock appears only as a link to it, whole.
188
201
  const draft = `${lock}.${token}.tmp`;
189
202
  writeFileSync(draft, token, { mode: 0o600, flag: 'wx' });
@@ -56,7 +56,10 @@ export declare function realDirectory(absolute: string): string | undefined;
56
56
  export declare function isWithin(child: string, parent: string): boolean;
57
57
  /** The list on disk, newest first; a missing, unreadable or malformed file is empty. */
58
58
  export declare function loadFolders(env?: NodeJS.ProcessEnv): OpenedFolder[];
59
- /** A lock older than this was left by a process that died holding it (a change takes milliseconds). */
59
+ /**
60
+ * A lock whose holder can't be told (its token can't be read) is stale once older than this; past it,
61
+ * one whose pid now runs another process is too (a change takes milliseconds).
62
+ */
60
63
  export declare const FOLDERS_LOCK_STALE_MS = 10000;
61
64
  /** How long a change waits for another process's change to the list before giving up. */
62
65
  export declare const FOLDERS_LOCK_WAIT_MS = 3000;
@@ -65,20 +68,24 @@ export declare const TOUCH_LOCK_WAIT_MS = 100;
65
68
  export declare class FoldersBusyError extends Error {
66
69
  constructor();
67
70
  }
68
- /** {@link takeOverStale} for the folders lock (its stale age). */
69
- export declare function takeOverStale(lock: string, nowMs?: number): void;
70
71
  /**
71
72
  * Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
72
73
  * created exclusively ({@link withFileLock}). The desktop app's `folders add`, a second `folders add`
73
74
  * 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.
75
+ * read the same list each write it back without the other's folder.
76
+ *
77
+ * Judged by its holder, the way the launch lock is (`liveHolders`): a lock whose holder still runs is
78
+ * never taken over, however old (a holder suspended while the Mac slept would wake up and write the
79
+ * list over its successor's), and one whose holder is gone is taken over at once: a dead pid, a lock
80
+ * from before the last boot, or a pid that now runs another process (its start time, carried in the
81
+ * token, tells them apart).
76
82
  *
77
83
  * @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
78
84
  */
79
85
  export declare function withFoldersLock<T>(env: NodeJS.ProcessEnv, work: () => T, options?: {
80
86
  waitMs?: number;
81
87
  now?: () => number;
88
+ processStart?: (pid: number) => number | undefined;
82
89
  }): T;
83
90
  /**
84
91
  * Open `input` on this Mac (`folders add`): it must be an existing directory;
@@ -22,7 +22,7 @@
22
22
  import { realpathSync, statSync } from 'node:fs';
23
23
  import os from 'node:os';
24
24
  import path from 'node:path';
25
- import { takeOverStale as takeOverStaleLock, withFileLock } from './filelock.js';
25
+ import { withFileLock } from './filelock.js';
26
26
  import { codeHostDir, readJsonObject, writePrivateJson } from './settings.js';
27
27
  export const FOLDERS_VERSION = 1;
28
28
  export const MAX_OPENED_FOLDERS = 50;
@@ -99,7 +99,10 @@ export function loadFolders(env = process.env) {
99
99
  function saveFolders(folders, env) {
100
100
  writePrivateJson(foldersPath(env), { v: FOLDERS_VERSION, folders: folders.slice(0, MAX_OPENED_FOLDERS) });
101
101
  }
102
- /** A lock older than this was left by a process that died holding it (a change takes milliseconds). */
102
+ /**
103
+ * A lock whose holder can't be told (its token can't be read) is stale once older than this; past it,
104
+ * one whose pid now runs another process is too (a change takes milliseconds).
105
+ */
103
106
  export const FOLDERS_LOCK_STALE_MS = 10_000;
104
107
  /** How long a change waits for another process's change to the list before giving up. */
105
108
  export const FOLDERS_LOCK_WAIT_MS = 3_000;
@@ -110,24 +113,27 @@ export class FoldersBusyError extends Error {
110
113
  super('The opened-folders list is busy (another prohost is changing it). Try again.');
111
114
  }
112
115
  }
113
- /** {@link takeOverStale} for the folders lock (its stale age). */
114
- export function takeOverStale(lock, nowMs = Date.now()) {
115
- takeOverStaleLock(lock, FOLDERS_LOCK_STALE_MS, nowMs);
116
- }
117
116
  /**
118
117
  * Run `work` (a read-modify-write of `folders.json`) holding the list's lock: `folders.json.lock`,
119
118
  * created exclusively ({@link withFileLock}). The desktop app's `folders add`, a second `folders add`
120
119
  * 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.
120
+ * read the same list each write it back without the other's folder.
121
+ *
122
+ * Judged by its holder, the way the launch lock is (`liveHolders`): a lock whose holder still runs is
123
+ * never taken over, however old (a holder suspended while the Mac slept would wake up and write the
124
+ * list over its successor's), and one whose holder is gone is taken over at once: a dead pid, a lock
125
+ * from before the last boot, or a pid that now runs another process (its start time, carried in the
126
+ * token, tells them apart).
123
127
  *
124
128
  * @throws FoldersBusyError when another process holds it past {@link FOLDERS_LOCK_WAIT_MS}.
125
129
  */
126
130
  export function withFoldersLock(env, work, options = {}) {
127
131
  const held = withFileLock(`${foldersPath(env)}.lock`, work, {
128
132
  staleMs: FOLDERS_LOCK_STALE_MS,
133
+ liveHolders: true,
129
134
  waitMs: options.waitMs ?? FOLDERS_LOCK_WAIT_MS,
130
135
  now: options.now,
136
+ processStart: options.processStart,
131
137
  });
132
138
  if (!held.ok)
133
139
  throw new FoldersBusyError();
@@ -16,23 +16,16 @@
16
16
  * its hooks inherit), recorded in `<state>/<terminal>.launch` as pending until
17
17
  * the new process's own first hook runs; meanwhile no other hook marks the
18
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.
19
+ * pending (no reply types) until its own hook runs, or until the daemon sees the
20
+ * window still runs the very process it was to replace ({@link abandonLaunch}):
21
+ * then it never ran, and the window gets back the launch it ran before. A second
22
+ * restart while one is pending supersedes it ({@link beginLaunch}); undoing the
23
+ * second leaves the first as it is by then, never as it was before. A window with
24
+ * no launch record (one an older build started) is judged by the mark alone, as
25
+ * before.
22
26
  */
23
27
  /** The launch id a window's agent process runs under, in its environment (and so its hooks'). */
24
28
  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.
30
- */
31
- interface LaunchRecord {
32
- launch: string | null;
33
- next?: string;
34
- gen: number;
35
- }
36
29
  export declare function readyPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
37
30
  export declare function launchPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
38
31
  /**
@@ -42,11 +35,23 @@ export declare function launchPath(terminalId: string, env?: NodeJS.ProcessEnv):
42
35
  export declare function currentLaunch(terminalId: string, env?: NodeJS.ProcessEnv): string | null | undefined;
43
36
  /** The launch waiting for its first hook, if one is. */
44
37
  export declare function pendingLaunch(terminalId: string, env?: NodeJS.ProcessEnv): string | undefined;
38
+ /** The pane process a relaunch replaces: its pid, and when it started (ms), if that was known. */
39
+ export interface ReplacedPane {
40
+ pid: number;
41
+ startedAtMs?: number;
42
+ }
43
+ /** A relaunch waiting for its first hook, with the pane process it replaces, if one is (and that pid is known). */
44
+ export declare function pendingRelaunch(terminalId: string, env?: NodeJS.ProcessEnv): {
45
+ launch: string;
46
+ pane: ReplacedPane;
47
+ } | undefined;
45
48
  /**
46
49
  * A hook of launch `launch` ran in the window (by default the caller's own, from its environment):
47
50
  * mark the window past its trust dialog. Atomic (`writePrivateJson`).
48
51
  *
49
52
  * - The pending launch's own hook proves it runs: it becomes the window's launch, and is marked.
53
+ * - A hook of a launch the pending one superseded proves that one ran: it becomes the window's
54
+ * launch (what an undone restart goes back to), but the window stays unmarked while one is pending.
50
55
  * - While a launch is pending, any other hook is of a process being replaced: it has no say, however
51
56
  * late it runs (a hook suspended while the Mac slept may resume long after).
52
57
  * - With nothing pending, a hook of another launch is of the process the window runs (an older
@@ -64,21 +69,24 @@ export declare function clearReady(terminalId: string, env?: NodeJS.ProcessEnv):
64
69
  /**
65
70
  * Launch `launch` is about to run in the window (a start or a restart): pending until its own first
66
71
  * 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.
72
+ * `replacing`: the pane process it replaces (a restart: its pid and start), so a relaunch that never
73
+ * ran can be told later ({@link abandonLaunch}). A launch still pending is superseded: it was seen running, and its
74
+ * own hook still makes it the window's launch. Returns whether both were done; on `false` the
75
+ * caller must not launch.
68
76
  */
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;
77
+ export declare function beginLaunch(terminalId: string, launch: string, env?: NodeJS.ProcessEnv, now?: number, replacing?: ReplacedPane): boolean;
76
78
  /**
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.
79
+ * Launch `launch` never ran (nothing was replaced: tmux refused, or the window still runs the
80
+ * process it was to replace): taken back out of the record as the record is now, never as it was
81
+ * before it began, so whatever settled meanwhile stands. Only while it is still the pending one.
82
+ * - It superseded a launch still pending: that one is pending again (it was seen running).
83
+ * - Otherwise the window runs its launch again, marked if that one was past its screens (its own
84
+ * hook ran, before or meanwhile).
85
+ *
86
+ * Returns `false` when the record couldn't be read or written: the launch then stays pending (no
87
+ * reply types), which fails closed.
80
88
  */
81
- export declare function restoreLaunch(terminalId: string, state: LaunchState, env?: NodeJS.ProcessEnv, now?: number): void;
89
+ export declare function abandonLaunch(terminalId: string, launch: string, env?: NodeJS.ProcessEnv, now?: number): boolean;
82
90
  /**
83
91
  * The transcript files (`<session>.jsonl`) Claude Code keeps for `cwd` under `configDir`: one exists
84
92
  * for a session only once its first prompt ran. Empty when there are none or they can't be read.
@@ -90,4 +98,3 @@ export declare function claudeSessionSince(cwd: string, configDir: string, since
90
98
  * pending), `false` not yet, `undefined` when the state can't be read.
91
99
  */
92
100
  export declare function isReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean | undefined;
93
- export {};
@@ -16,9 +16,13 @@
16
16
  * its hooks inherit), recorded in `<state>/<terminal>.launch` as pending until
17
17
  * the new process's own first hook runs; meanwhile no other hook marks the
18
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.
19
+ * pending (no reply types) until its own hook runs, or until the daemon sees the
20
+ * window still runs the very process it was to replace ({@link abandonLaunch}):
21
+ * then it never ran, and the window gets back the launch it ran before. A second
22
+ * restart while one is pending supersedes it ({@link beginLaunch}); undoing the
23
+ * second leaves the first as it is by then, never as it was before. A window with
24
+ * no launch record (one an older build started) is judged by the mark alone, as
25
+ * before.
22
26
  */
23
27
  import { closeSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync } from 'node:fs';
24
28
  import path from 'node:path';
@@ -27,6 +31,8 @@ import { codeHostDir, writePrivateJson } from './settings.js';
27
31
  import { isValidId } from './tmux.js';
28
32
  /** The launch id a window's agent process runs under, in its environment (and so its hooks'). */
29
33
  export const LAUNCH_ENV = 'PROHOST_LAUNCH_ID';
34
+ /** How many superseded launches a record keeps: an older one's hook then has no say (fails closed). */
35
+ const MAX_SUPERSEDED = 8;
30
36
  export function readyPath(terminalId, env = process.env) {
31
37
  if (!isValidId(terminalId))
32
38
  throw new Error('invalid terminal id');
@@ -53,7 +59,22 @@ function readLaunch(terminalId, env) {
53
59
  return undefined;
54
60
  if (parsed.next !== undefined && typeof parsed.next !== 'string')
55
61
  return undefined;
56
- return { launch, gen: parsed.gen, ...(parsed.next !== undefined ? { next: parsed.next } : {}) };
62
+ const record = { launch, gen: parsed.gen };
63
+ if (parsed.next !== undefined) {
64
+ record.next = parsed.next;
65
+ const superseded = Array.isArray(parsed.superseded) ? parsed.superseded.filter((id) => typeof id === 'string') : [];
66
+ if (superseded.length > 0)
67
+ record.superseded = superseded;
68
+ if (Number.isInteger(parsed.pane) && parsed.pane > 0) {
69
+ record.pane = parsed.pane;
70
+ if (typeof parsed.paneStartedAtMs === 'number' && Number.isFinite(parsed.paneStartedAtMs)) {
71
+ record.paneStartedAtMs = parsed.paneStartedAtMs;
72
+ }
73
+ }
74
+ }
75
+ if (parsed.launchReady === true)
76
+ record.launchReady = true;
77
+ return record;
57
78
  }
58
79
  catch {
59
80
  return undefined;
@@ -100,11 +121,40 @@ export function currentLaunch(terminalId, env = process.env) {
100
121
  export function pendingLaunch(terminalId, env = process.env) {
101
122
  return readLaunch(terminalId, env)?.next;
102
123
  }
124
+ /** A relaunch waiting for its first hook, with the pane process it replaces, if one is (and that pid is known). */
125
+ export function pendingRelaunch(terminalId, env = process.env) {
126
+ const record = readLaunch(terminalId, env);
127
+ if (record?.next === undefined || record.pane === undefined)
128
+ return undefined;
129
+ return {
130
+ launch: record.next,
131
+ pane: { pid: record.pane, ...(record.paneStartedAtMs !== undefined ? { startedAtMs: record.paneStartedAtMs } : {}) },
132
+ };
133
+ }
134
+ /** The record as written: only the fields it has. */
135
+ function recordOf(fields) {
136
+ const record = { launch: fields.launch, gen: fields.gen };
137
+ if (fields.next !== undefined) {
138
+ record.next = fields.next;
139
+ if (fields.superseded && fields.superseded.length > 0)
140
+ record.superseded = fields.superseded;
141
+ if (fields.pane !== undefined) {
142
+ record.pane = fields.pane;
143
+ if (fields.paneStartedAtMs !== undefined)
144
+ record.paneStartedAtMs = fields.paneStartedAtMs;
145
+ }
146
+ }
147
+ if (fields.launchReady)
148
+ record.launchReady = true;
149
+ return record;
150
+ }
103
151
  /**
104
152
  * A hook of launch `launch` ran in the window (by default the caller's own, from its environment):
105
153
  * mark the window past its trust dialog. Atomic (`writePrivateJson`).
106
154
  *
107
155
  * - The pending launch's own hook proves it runs: it becomes the window's launch, and is marked.
156
+ * - A hook of a launch the pending one superseded proves that one ran: it becomes the window's
157
+ * launch (what an undone restart goes back to), but the window stays unmarked while one is pending.
108
158
  * - While a launch is pending, any other hook is of a process being replaced: it has no say, however
109
159
  * late it runs (a hook suspended while the Mac slept may resume long after).
110
160
  * - With nothing pending, a hook of another launch is of the process the window runs (an older
@@ -139,6 +189,14 @@ function markReadyLocked(terminalId, env, launch, now) {
139
189
  let current = record;
140
190
  if (record.next !== undefined) {
141
191
  // Pending: only its own hook settles it (or the daemon, knowing nothing was replaced).
192
+ const superseded = record.superseded ?? [];
193
+ const at = launch === null ? -1 : superseded.indexOf(launch);
194
+ if (at >= 0) {
195
+ // A launch the pending one superseded ran, and is past its screens: the window's launch if the
196
+ // pending one never runs. Every launch before it is gone (it replaced them), their hooks too.
197
+ writePrivateJson(launchPath(terminalId, env), recordOf({ ...record, launch, superseded: superseded.slice(at + 1), launchReady: true, gen: nextGen(record, now) }));
198
+ return true;
199
+ }
142
200
  if (launch !== record.next)
143
201
  return true;
144
202
  current = { launch, gen: nextGen(record, now) };
@@ -177,17 +235,32 @@ export function clearReady(terminalId, env = process.env) {
177
235
  /**
178
236
  * Launch `launch` is about to run in the window (a start or a restart): pending until its own first
179
237
  * 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.
238
+ * `replacing`: the pane process it replaces (a restart: its pid and start), so a relaunch that never
239
+ * ran can be told later ({@link abandonLaunch}). A launch still pending is superseded: it was seen running, and its
240
+ * own hook still makes it the window's launch. Returns whether both were done; on `false` the
241
+ * caller must not launch.
181
242
  */
182
- export function beginLaunch(terminalId, launch, env = process.env, now = Date.now()) {
243
+ export function beginLaunch(terminalId, launch, env = process.env, now = Date.now(), replacing) {
183
244
  if (!isValidId(terminalId) || launch === '')
184
245
  return false;
185
246
  return withLaunchLock(terminalId, env, false, () => {
186
247
  const record = readLaunch(terminalId, env);
187
248
  if (record === undefined)
188
249
  return false;
250
+ // What the window is again if this launch (and any it supersedes) never runs, read under the lock.
251
+ const launchReady = record?.next !== undefined ? record.launchReady === true : isReady(terminalId, env) === true;
252
+ const superseded = record?.next !== undefined ? [...(record.superseded ?? []), record.next].slice(-MAX_SUPERSEDED) : [];
189
253
  try {
190
- writePrivateJson(launchPath(terminalId, env), { launch: record?.launch ?? null, next: launch, gen: nextGen(record, now) });
254
+ writePrivateJson(launchPath(terminalId, env), recordOf({
255
+ launch: record?.launch ?? null,
256
+ next: launch,
257
+ superseded,
258
+ ...(replacing !== undefined && replacing.pid > 0
259
+ ? { pane: replacing.pid, ...(replacing.startedAtMs !== undefined ? { paneStartedAtMs: replacing.startedAtMs } : {}) }
260
+ : {}),
261
+ launchReady,
262
+ gen: nextGen(record, now),
263
+ }));
191
264
  }
192
265
  catch {
193
266
  return false;
@@ -195,43 +268,44 @@ export function beginLaunch(terminalId, launch, env = process.env, now = Date.no
195
268
  return clearReady(terminalId, env);
196
269
  });
197
270
  }
198
- export function launchState(terminalId, env = process.env) {
199
- return { record: readLaunch(terminalId, env), ready: isReady(terminalId, env) };
200
- }
201
271
  /**
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.
272
+ * Launch `launch` never ran (nothing was replaced: tmux refused, or the window still runs the
273
+ * process it was to replace): taken back out of the record as the record is now, never as it was
274
+ * before it began, so whatever settled meanwhile stands. Only while it is still the pending one.
275
+ * - It superseded a launch still pending: that one is pending again (it was seen running).
276
+ * - Otherwise the window runs its launch again, marked if that one was past its screens (its own
277
+ * hook ran, before or meanwhile).
278
+ *
279
+ * Returns `false` when the record couldn't be read or written: the launch then stays pending (no
280
+ * reply types), which fails closed.
205
281
  */
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;
282
+ export function abandonLaunch(terminalId, launch, env = process.env, now = Date.now()) {
283
+ if (!isValidId(terminalId) || launch === '')
284
+ return false;
285
+ return withLaunchLock(terminalId, env, false, () => {
286
+ const record = readLaunch(terminalId, env);
287
+ if (record === undefined)
288
+ return false;
289
+ // Not pending (its own hook ran after all) or another one is: nothing of it to take back.
290
+ if (record === null || record.next !== launch)
291
+ return true;
292
+ const superseded = record.superseded ?? [];
293
+ const gen = nextGen(record, now);
294
+ try {
295
+ const previous = superseded.at(-1);
296
+ if (previous !== undefined) {
297
+ writePrivateJson(launchPath(terminalId, env), recordOf({ launch: record.launch, next: previous, superseded: superseded.slice(0, -1), launchReady: record.launchReady, gen }));
298
+ return true;
299
+ }
300
+ writePrivateJson(launchPath(terminalId, env), recordOf({ launch: record.launch, gen }));
301
+ if (record.launchReady)
302
+ writePrivateJson(readyPath(terminalId, env), { launch: record.launch, gen, at: now });
303
+ return true;
219
304
  }
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 });
305
+ catch {
306
+ return false;
230
307
  }
231
- }
232
- catch {
233
- /* left pending (see above): fails closed */
234
- }
308
+ });
235
309
  }
236
310
  /** How much of a transcript is read looking for its folder (its first entries carry `cwd`). */
237
311
  const TRANSCRIPT_HEAD_BYTES = 256 * 1024;
@@ -393,6 +393,11 @@ export declare class CodeTerminals {
393
393
  private lastListUncertain;
394
394
  /** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
395
395
  private panePids;
396
+ /**
397
+ * Terminals whose restart is between recording its launch and its last look: their pane may be
398
+ * mid-replacement, so no `adopt` judges their pending launch meanwhile ({@link settleRelaunches}).
399
+ */
400
+ private readonly relaunching;
396
401
  /** Every thread window's pane process at the last `adopt`, by window name: what an end makes sure exits. */
397
402
  private windowPids;
398
403
  /** Per terminal: how to close each viewer attached to it now (an end closes them before its windows). */
@@ -450,6 +455,17 @@ export declare class CodeTerminals {
450
455
  */
451
456
  reloadConfig(): Promise<boolean>;
452
457
  adopt(): Promise<number>;
458
+ /**
459
+ * A restart nobody could confirm (tmux timed out, its window couldn't be looked at), or one this
460
+ * daemon died in the middle of, leaves its launch pending. Once a look shows the window still runs
461
+ * the very pane process that launch was to replace (its pid, started when that one did: a reused
462
+ * pid is another process), it never ran: the old process is the window's again, with its launch
463
+ * and readiness ({@link abandonLaunch}), so replies reach the process that is really there instead
464
+ * of waiting for a hook that never comes. A pane that changed keeps it pending, as does one whose
465
+ * start can't be told (fails closed): the new process's own hook settles it. Judged by process,
466
+ * never by time.
467
+ */
468
+ private settleRelaunches;
453
469
  /**
454
470
  * The environment a Claude Code thread starts with: what `startOnce` hands its window, and so
455
471
  * the only source of where that window's Claude Code keeps its sessions (`adoptReadiness`).
@@ -544,8 +560,8 @@ export declare class CodeTerminals {
544
560
  * Of panes an earlier attempt saved, those whose process group is still this terminal's. A group
545
561
  * outlives its leader, and a pid is never handed out while a group of that id exists: so one whose
546
562
  * leader pid runs no process now is ours if anything is left in it. One whose pid runs a process is
547
- * ours only if that process started when the saved one did; one with no saved start, or when `ps`
548
- * can't say, is left alone.
563
+ * ours only if that process started when the saved one did; one with no saved start is left alone.
564
+ * `undefined` when `ps` couldn't say: nothing is judged, and the caller keeps them for a retry.
549
565
  */
550
566
  private stillOurs;
551
567
  restart(request: RestartRequest, outcome?: RestartOutcome, hooks?: RestartHooks): Promise<boolean>;
@@ -36,7 +36,7 @@ import { loginOf, readSession } from './session.js';
36
36
  import { codeHostDir, writePrivateJson } from './settings.js';
37
37
  import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
38
38
  import { MAX_OPEN_PTYS } from './pty.js';
39
- import { beginLaunch, claudeSessionSince, claudeTranscripts, currentLaunch, isReady, LAUNCH_ENV, launchState, markReady, restoreLaunch, } from './ready.js';
39
+ import { abandonLaunch, beginLaunch, claudeSessionSince, claudeTranscripts, currentLaunch, isReady, LAUNCH_ENV, markReady, pendingRelaunch, } from './ready.js';
40
40
  import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
41
41
  import { noteInputTakeover } from './sizing.js';
42
42
  import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, respawnPaneArgs, killSessionArgs, killWindowArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
@@ -270,6 +270,11 @@ export class CodeTerminals {
270
270
  lastListUncertain = false;
271
271
  /** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
272
272
  panePids = new Map();
273
+ /**
274
+ * Terminals whose restart is between recording its launch and its last look: their pane may be
275
+ * mid-replacement, so no `adopt` judges their pending launch meanwhile ({@link settleRelaunches}).
276
+ */
277
+ relaunching = new Set();
273
278
  /** Every thread window's pane process at the last `adopt`, by window name: what an end makes sure exits. */
274
279
  windowPids = new Map();
275
280
  /** Per terminal: how to close each viewer attached to it now (an end closes them before its windows). */
@@ -435,6 +440,7 @@ export class CodeTerminals {
435
440
  this.records.set(id, record);
436
441
  this.panePids = panes;
437
442
  this.windowPids = windowPids;
443
+ await this.settleRelaunches([...found.values()], panes);
438
444
  // Once per daemon start: the windows an older build left behind are all there now. Started by
439
445
  // the first call; every call waits for it, so none returns before those windows are marked. A
440
446
  // pass that fails (a keychain still locked right after login) never fails an adopt: it is
@@ -452,6 +458,42 @@ export class CodeTerminals {
452
458
  this.touchLiveLanes();
453
459
  return this.records.size;
454
460
  }
461
+ /**
462
+ * A restart nobody could confirm (tmux timed out, its window couldn't be looked at), or one this
463
+ * daemon died in the middle of, leaves its launch pending. Once a look shows the window still runs
464
+ * the very pane process that launch was to replace (its pid, started when that one did: a reused
465
+ * pid is another process), it never ran: the old process is the window's again, with its launch
466
+ * and readiness ({@link abandonLaunch}), so replies reach the process that is really there instead
467
+ * of waiting for a hook that never comes. A pane that changed keeps it pending, as does one whose
468
+ * start can't be told (fails closed): the new process's own hook settles it. Judged by process,
469
+ * never by time.
470
+ */
471
+ async settleRelaunches(records, panes) {
472
+ const env = { PROHOST_HOME: this.options.home };
473
+ const candidates = [];
474
+ for (const record of records) {
475
+ if (record.agent !== 'claude_code' || this.relaunching.has(record.terminalId))
476
+ continue;
477
+ const pending = pendingRelaunch(record.terminalId, env);
478
+ if (!pending || pending.pane.startedAtMs === undefined || panes.get(record.terminalId) !== pending.pane.pid)
479
+ continue;
480
+ candidates.push({ record, launch: pending.launch, pid: pending.pane.pid, startedAtMs: pending.pane.startedAtMs });
481
+ }
482
+ if (candidates.length === 0)
483
+ return;
484
+ const starts = await this.startsOf(candidates.map((candidate) => candidate.pid));
485
+ for (const { record, launch, pid, startedAtMs } of candidates) {
486
+ const now = starts.get(pid);
487
+ if (now === undefined || Math.abs(now - startedAtMs) > PANE_START_SLACK_MS)
488
+ continue;
489
+ // A restart of it may have begun while `ps` ran: its window is its own until its last look.
490
+ if (this.relaunching.has(record.terminalId))
491
+ continue;
492
+ if (abandonLaunch(record.terminalId, launch, env)) {
493
+ this.log(` ⚙ code thread ${record.codeThreadId.slice(0, 12)}: its restart never ran; the agent it was to replace still runs`);
494
+ }
495
+ }
496
+ }
455
497
  /**
456
498
  * The environment a Claude Code thread starts with: what `startOnce` hands its window, and so
457
499
  * the only source of where that window's Claude Code keeps its sessions (`adoptReadiness`).
@@ -856,7 +898,22 @@ export class CodeTerminals {
856
898
  this.records.delete(request.terminalId);
857
899
  this.panePids.delete(request.terminalId);
858
900
  this.lastInterrupt.delete(request.terminalId);
859
- await this.reapGroups(fresh.map((entry) => entry.pid), await this.stillOurs(saved));
901
+ const ours = await this.stillOurs(saved);
902
+ if (ours === undefined) {
903
+ // `ps` couldn't say which saved panes are still this terminal's: what was just closed is reaped,
904
+ // the rest stays saved in the unfinished mark, and the end is asked for again rather than said done.
905
+ await this.reapGroups(fresh.map((entry) => entry.pid));
906
+ try {
907
+ this.markEnded(request, saved, false);
908
+ }
909
+ catch {
910
+ /* the earlier mark still holds them */
911
+ }
912
+ this.log(` ! code thread ${short}: couldn't check what is left of its terminal; retrying`);
913
+ outcome.retry = true;
914
+ return false;
915
+ }
916
+ await this.reapGroups(fresh.map((entry) => entry.pid), ours);
860
917
  try {
861
918
  // Finished and reaped: nothing left for a retry to signal, and a late redelivery is answered ended.
862
919
  this.markEnded(request, [], true);
@@ -899,8 +956,8 @@ export class CodeTerminals {
899
956
  * Of panes an earlier attempt saved, those whose process group is still this terminal's. A group
900
957
  * outlives its leader, and a pid is never handed out while a group of that id exists: so one whose
901
958
  * leader pid runs no process now is ours if anything is left in it. One whose pid runs a process is
902
- * ours only if that process started when the saved one did; one with no saved start, or when `ps`
903
- * can't say, is left alone.
959
+ * ours only if that process started when the saved one did; one with no saved start is left alone.
960
+ * `undefined` when `ps` couldn't say: nothing is judged, and the caller keeps them for a retry.
904
961
  */
905
962
  async stillOurs(saved) {
906
963
  if (saved.length === 0)
@@ -910,7 +967,7 @@ export class CodeTerminals {
910
967
  starts = await (this.options.paneStarts ?? paneStarts)(saved.map((entry) => entry.pid));
911
968
  }
912
969
  catch {
913
- return [];
970
+ return undefined;
914
971
  }
915
972
  return saved
916
973
  .filter((entry) => {
@@ -924,7 +981,14 @@ export class CodeTerminals {
924
981
  async restart(request, outcome = {}, hooks = {}) {
925
982
  if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId))
926
983
  return false;
927
- return this.serialize(request.terminalId, () => this.restartNow(request, outcome, hooks));
984
+ return this.serialize(request.terminalId, async () => {
985
+ try {
986
+ return await this.restartNow(request, outcome, hooks);
987
+ }
988
+ finally {
989
+ this.relaunching.delete(request.terminalId);
990
+ }
991
+ });
928
992
  }
929
993
  /** Once every typing job and restart already queued for the terminal has finished (never rejects). */
930
994
  async settled(terminalId) {
@@ -997,6 +1061,21 @@ export class CodeTerminals {
997
1061
  // Like a new window: replies wait for the new Claude Code's first hook (`ready.ts`). Its launch is
998
1062
  // recorded (and the old mark dropped) before the old process goes, so that hook's mark is never
999
1063
  // wiped after it was written, and a late hook of the old process can't mark the new one.
1064
+ const before = this.panePids.get(record.terminalId);
1065
+ // When the pane process to be replaced started: with its pid, what tells later that it still runs.
1066
+ const beforeStartedAtMs = agent === 'claude_code' && before !== undefined ? (await this.startsOf([before])).get(before) : undefined;
1067
+ const earlier = agent === 'claude_code' && before !== undefined ? pendingRelaunch(record.terminalId, env) : undefined;
1068
+ if (earlier &&
1069
+ earlier.pane.pid === before &&
1070
+ earlier.pane.startedAtMs !== undefined &&
1071
+ (beforeStartedAtMs === undefined || Math.abs(beforeStartedAtMs - earlier.pane.startedAtMs) <= PANE_START_SLACK_MS)) {
1072
+ // An earlier relaunch that never ran (or may not have: `ps` didn't say) is still recorded as
1073
+ // pending (`adopt` couldn't take it back): superseded now, it would be waited on forever. Tried
1074
+ // again once it is settled.
1075
+ this.log(` ! code thread ${short} not restarted yet: an earlier restart of it isn't settled`);
1076
+ outcome.retry = true;
1077
+ return false;
1078
+ }
1000
1079
  // The switch once more, as late as it can be: nothing awaits between here and `respawn-pane`, so a
1001
1080
  // `remote off` that landed during the looks above stops it before anything is claimed or touched.
1002
1081
  if (request.skipPermissions && !this.canType())
@@ -1012,11 +1091,14 @@ export class CodeTerminals {
1012
1091
  outcome.repeat = true;
1013
1092
  return false;
1014
1093
  }
1015
- const before = this.panePids.get(record.terminalId);
1016
- const previous = agent === 'claude_code' ? launchState(record.terminalId, env) : undefined;
1017
- if (previous && !beginLaunch(record.terminalId, token, env)) {
1094
+ const launched = agent === 'claude_code';
1095
+ // From here until its last look, no `adopt` judges this window's pending launch (`settleRelaunches`).
1096
+ if (launched)
1097
+ this.relaunching.add(record.terminalId);
1098
+ const replacing = before === undefined ? undefined : { pid: before, ...(beforeStartedAtMs !== undefined ? { startedAtMs: beforeStartedAtMs } : {}) };
1099
+ if (launched && !beginLaunch(record.terminalId, token, env, Date.now(), replacing)) {
1018
1100
  // Nothing was replaced, and nothing may be until the new launch can be told from the old one.
1019
- restoreLaunch(record.terminalId, previous, env);
1101
+ abandonLaunch(record.terminalId, token, env);
1020
1102
  this.log(` ! code thread ${short} not restarted yet: its launch could not be recorded`);
1021
1103
  outcome.retry = true;
1022
1104
  outcome.release = true;
@@ -1039,8 +1121,8 @@ export class CodeTerminals {
1039
1121
  }));
1040
1122
  if (!respawned.ok && !respawned.uncertain) {
1041
1123
  // Nothing was replaced: the old process (its launch and readiness) stays, and it is worth sending again.
1042
- if (previous)
1043
- restoreLaunch(record.terminalId, previous, env);
1124
+ if (launched)
1125
+ abandonLaunch(record.terminalId, token, env);
1044
1126
  this.log(` ! code thread ${short} not restarted: tmux said ${respawned.stderr.trim()}`);
1045
1127
  outcome.retry = true;
1046
1128
  outcome.release = true;
@@ -1052,8 +1134,8 @@ export class CodeTerminals {
1052
1134
  const after = await this.panePidNow(record.terminalId);
1053
1135
  if (after !== undefined && after === before) {
1054
1136
  // The same process still runs: nothing was replaced. It keeps its launch and readiness, and is sent again.
1055
- if (previous)
1056
- restoreLaunch(record.terminalId, previous, env);
1137
+ if (launched)
1138
+ abandonLaunch(record.terminalId, token, env);
1057
1139
  this.log(` ! code thread ${short} not restarted: its window still runs the same process`);
1058
1140
  outcome.retry = true;
1059
1141
  outcome.release = true;
@@ -1061,8 +1143,9 @@ export class CodeTerminals {
1061
1143
  }
1062
1144
  const replaced = after !== undefined && before !== undefined;
1063
1145
  if (!replaced && !respawned.ok) {
1064
- // Can't be told (tmux timed out and didn't answer the look): the old process may still run, so
1065
- // its state stays as it was. Never done twice, never reported done.
1146
+ // Can't be told (tmux timed out and didn't answer the look): the old process may still run. The
1147
+ // launch stays pending, with the pane it was to replace, until a look tells (`settleRelaunches`:
1148
+ // the next `adopt`, or a reply it would refuse). Never done twice, never reported done.
1066
1149
  this.log(` ! code thread ${short}: couldn't confirm ${agent} restarted; it may or may not have`);
1067
1150
  outcome.refused = 'restart_unconfirmed';
1068
1151
  return false;
@@ -1270,6 +1353,12 @@ export class CodeTerminals {
1270
1353
  // Typed only once a hook has run in the window (`ready.ts`): before that its trust dialog
1271
1354
  // is up or about to be drawn, and an Enter would answer it. Never judged from the screen.
1272
1355
  let ready = this.isReady(record.terminalId);
1356
+ if (ready === false && pendingRelaunch(record.terminalId, { PROHOST_HOME: this.options.home }) !== undefined) {
1357
+ // A restart nobody could confirm: one look tells whether it ever ran (`settleRelaunches`). If
1358
+ // it didn't, the process it was to replace still runs, and is the one this reply is for.
1359
+ await this.adopt();
1360
+ ready = this.isReady(record.terminalId);
1361
+ }
1273
1362
  if (ready === false && !record.readyMark) {
1274
1363
  // A window an older build started: its readiness pass may be running, or have failed
1275
1364
  // (then this tries it once more, now, rather than at the next adopt).
@@ -32,7 +32,7 @@ import { journalPath, readEvents } from './events.js';
32
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
- import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
35
+ import { adoptComputer, adoptedRepos, loadAdopted, runMigrate } from './migrate.js';
36
36
  import { runHostPair, writeHostPairResult } from './pair.js';
37
37
  import { RESTART_EXIT_CODE, runHostService } from './service.js';
38
38
  import { STATE_STALE_MS, localLogin, localRunning, slotsFor } from './status.js';
@@ -697,6 +697,7 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
697
697
  ...(workload === 'coding' || workload === 'chat' ? { workload: workload } : {}),
698
698
  allowUnverified: flags['allow-unverified'] === true,
699
699
  workdirFor: (agentId) => adopted?.homes.find((home) => home.agent_id === agentId && !home.host_only)?.hosted_workdir,
700
+ codeHostRepos: adoptedRepos(adopted ?? undefined, codeHostHome),
700
701
  onConfig: (config) => legacy?.sync(new Set(config.placements.map((p) => p.agent_id)), true),
701
702
  });
702
703
  if (result.fatal)
@@ -162,4 +162,9 @@ export declare function adoptComputer(options: {
162
162
  facts?: MachineFacts;
163
163
  name?: string;
164
164
  }): Promise<ComputerAdoption>;
165
+ /**
166
+ * The checkouts this computer's terminal sessions may start in: the `--repos` its adopted homes
167
+ * ran with (the code-host home's first), each once. A home that ran with none adds none.
168
+ */
169
+ export declare function adoptedRepos(adopted: Adopted | undefined, codeHostHome: string | undefined): string[];
165
170
  export {};
@@ -366,3 +366,17 @@ export async function adoptComputer(options) {
366
366
  }
367
367
  return { status: 'not_adopted', reason };
368
368
  }
369
+ /**
370
+ * The checkouts this computer's terminal sessions may start in: the `--repos` its adopted homes
371
+ * ran with (the code-host home's first), each once. A home that ran with none adds none.
372
+ */
373
+ export function adoptedRepos(adopted, codeHostHome) {
374
+ const homes = [...(adopted?.homes ?? [])].sort((a, b) => Number(b.home === codeHostHome) - Number(a.home === codeHostHome));
375
+ const repos = [];
376
+ for (const home of homes) {
377
+ for (const repo of home.repos ?? [])
378
+ if (repo && !repos.includes(repo))
379
+ repos.push(repo);
380
+ }
381
+ return repos;
382
+ }
@@ -75,6 +75,11 @@ export interface HostServiceOptions {
75
75
  workdirFor?: (agentId: string) => string | undefined;
76
76
  /** Called with each config the server sends (placements decide which adopted homes still run on their own). */
77
77
  onConfig?: (config: ComputerConfig) => void;
78
+ /**
79
+ * The checkouts terminal sessions may start in (`--repos`, else what the adopted homes ran
80
+ * with): declared on every status post, and the only repositories a start is taken for.
81
+ */
82
+ codeHostRepos?: string[];
78
83
  webSocketCtor?: typeof WebSocket;
79
84
  fetchImpl?: typeof fetch;
80
85
  spawnImpl?: typeof spawn;
@@ -227,7 +227,7 @@ async function serveHost(options) {
227
227
  machine,
228
228
  clientVersion: CLI_VERSION,
229
229
  accountId: computer.account.id,
230
- repos: [],
230
+ repos: options.codeHostRepos ?? [],
231
231
  cliEntrypoint: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js'),
232
232
  log,
233
233
  slots: () => ({ busy: active.size + terminals(), total: capacity() }),
@@ -270,6 +270,18 @@ async function serveHost(options) {
270
270
  }
271
271
  return caps;
272
272
  }
273
+ /**
274
+ * What a phone may start a terminal session in here, while this computer hosts them (the same
275
+ * rule as `code_threads_v2` in {@link capabilities}): its checkouts and the folders it opened.
276
+ */
277
+ function codeHostReport() {
278
+ if (!codeHost?.authorized() || !config.terminal_sessions_enabled)
279
+ return undefined;
280
+ const opened = codeHost.openedFolders?.();
281
+ if (!opened)
282
+ return undefined;
283
+ return { repos: codeHost.offeredRepos?.(), folders: opened.folders, home: opened.home };
284
+ }
273
285
  // ── What is running ──────────────────────────────────────────────────────
274
286
  function runningEntries() {
275
287
  const runs = [...active.values()].map((run) => ({ ...run.entry, peak_mem_mb: peak.peakMb(run.entry.run_id) }));
@@ -370,6 +382,7 @@ async function serveHost(options) {
370
382
  capabilities: capabilities(),
371
383
  recommendedCapacity: recommended(),
372
384
  dedicated: env[DEDICATED_ENV] === '1',
385
+ codeHost: codeHostReport(),
373
386
  });
374
387
  const response = await callJson(computerApi, statusPath(computer.computer_id), body);
375
388
  if (response.status >= 200 && response.status < 300) {
@@ -7,6 +7,7 @@
7
7
  * stay in the local files only.
8
8
  */
9
9
  import type { AccountRuntime } from '../agent/accounts.js';
10
+ import type { ReportedFolder } from '../codehost/folders.js';
10
11
  import type { HostStats } from '../codehost/host_stats.js';
11
12
  import type { HeldReason } from './capacity.js';
12
13
  import type { LoginInfo } from './logins.js';
@@ -69,6 +70,15 @@ export interface StatusInputs {
69
70
  recommendedCapacity: number;
70
71
  /** Running as a dedicated service (keeps working when no one is logged in). */
71
72
  dedicated?: boolean;
73
+ /**
74
+ * What the terminal host has, while it hosts terminal sessions: its checkouts (`undefined` while
75
+ * it can't say), the folders this Mac has opened, and its home. Absent: no `code_host` is sent.
76
+ */
77
+ codeHost?: {
78
+ repos: string[] | undefined;
79
+ folders: readonly ReportedFolder[];
80
+ home: string;
81
+ };
72
82
  }
73
83
  /** `POST /v1/computers/{id}/status` body. */
74
84
  export declare function buildStatusBody(inputs: StatusInputs): Record<string, unknown>;
@@ -98,6 +98,8 @@ export function loginBody(login, conversations) {
98
98
  conversations,
99
99
  };
100
100
  }
101
+ /** The most folders and repositories a status post carries (the server keeps no more). */
102
+ const MAX_CODE_HOST_ENTRIES = 50;
101
103
  /** `POST /v1/computers/{id}/status` body. */
102
104
  export function buildStatusBody(inputs) {
103
105
  return {
@@ -119,6 +121,18 @@ export function buildStatusBody(inputs) {
119
121
  capabilities: [...inputs.capabilities],
120
122
  recommended_capacity: inputs.recommendedCapacity,
121
123
  ...(inputs.dedicated !== undefined ? { dedicated: inputs.dedicated } : {}),
124
+ ...(inputs.codeHost
125
+ ? {
126
+ code_host: {
127
+ // `null`: it can't say which (a list too long to declare); the server then refuses none.
128
+ repos: inputs.codeHost.repos ? inputs.codeHost.repos.slice(0, MAX_CODE_HOST_ENTRIES) : null,
129
+ folders: inputs.codeHost.folders
130
+ .slice(0, MAX_CODE_HOST_ENTRIES)
131
+ .map((folder) => ({ path: folder.path, git: folder.git })),
132
+ home: inputs.codeHost.home,
133
+ },
134
+ }
135
+ : {}),
122
136
  };
123
137
  }
124
138
  /** A run as `host status --json` and `activity.json` show it. */
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";
10
+ export declare const CLI_VERSION = "0.16.12";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.10";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.12";
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';
10
+ export const CLI_VERSION = '0.16.12';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.10",
3
+ "version": "0.16.12",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",