@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 +17 -0
- package/dist/codehost/filelock.d.ts +3 -3
- package/dist/codehost/filelock.js +33 -20
- package/dist/codehost/folders.d.ts +12 -5
- package/dist/codehost/folders.js +14 -8
- package/dist/codehost/ready.d.ts +34 -27
- package/dist/codehost/ready.js +114 -40
- package/dist/codehost/terminals.d.ts +18 -2
- package/dist/codehost/terminals.js +105 -16
- package/dist/host/command.js +2 -1
- package/dist/host/migrate.d.ts +5 -0
- package/dist/host/migrate.js +14 -0
- package/dist/host/service.d.ts +5 -0
- package/dist/host/service.js +14 -1
- package/dist/host/status.d.ts +10 -0
- package/dist/host/status.js +14 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,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
|
|
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);
|
|
19
|
-
*
|
|
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)
|
|
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
|
|
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:
|
|
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
|
|
92
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
/**
|
|
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.
|
|
75
|
-
*
|
|
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;
|
package/dist/codehost/folders.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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.
|
|
122
|
-
*
|
|
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();
|
package/dist/codehost/ready.d.ts
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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 {};
|
package/dist/codehost/ready.js
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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), {
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
548
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
903
|
-
*
|
|
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, () =>
|
|
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
|
|
1016
|
-
|
|
1017
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
1043
|
-
|
|
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 (
|
|
1056
|
-
|
|
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
|
|
1065
|
-
//
|
|
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).
|
package/dist/host/command.js
CHANGED
|
@@ -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)
|
package/dist/host/migrate.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/host/migrate.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/host/service.d.ts
CHANGED
|
@@ -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;
|
package/dist/host/service.js
CHANGED
|
@@ -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) {
|
package/dist/host/status.d.ts
CHANGED
|
@@ -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>;
|
package/dist/host/status.js
CHANGED
|
@@ -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
|
+
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.
|
|
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
|
+
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}`;
|