@prohost/cli 0.16.7 → 0.16.10
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 +47 -0
- package/dist/agent/run.js +8 -3
- package/dist/codehost/host.d.ts +24 -3
- package/dist/codehost/host.js +41 -1
- package/dist/codehost/terminals.d.ts +85 -0
- package/dist/codehost/terminals.js +303 -9
- package/dist/codehost/tmux.d.ts +2 -0
- package/dist/codehost/tmux.js +4 -0
- package/dist/host/command.js +99 -27
- package/dist/host/install.d.ts +7 -1
- package/dist/host/install.js +28 -5
- package/dist/host/service.js +9 -3
- 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,53 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.16.10
|
|
10
|
+
|
|
11
|
+
- **A Code thread's terminal can be ended from ProhostAI.** `code_thread.end_requested` closes the
|
|
12
|
+
thread's viewers, its agent and shell windows (and a shell left without its agent window), and
|
|
13
|
+
whatever of their process groups is left (SIGTERM, then SIGKILL after 2 s). Its windows were the
|
|
14
|
+
only record of it, so it is never adopted again and drops out of `running`, freeing its slot; its
|
|
15
|
+
folder is left as it is. Idempotent (a terminal already gone is ended), taken whatever the remote
|
|
16
|
+
switch says (ending never types), refused as `end_expired` past 10 minutes, retried when tmux
|
|
17
|
+
doesn't answer. Declared as `code_thread_end_session`; an older CLI never declares it and acks
|
|
18
|
+
the event ignored.
|
|
19
|
+
- An ended terminal leaves a mark (`state/<terminal>.ended`) holding its pane processes, each with
|
|
20
|
+
when it started and the boot it ran in: a start delivered again after a lost ack never relaunches
|
|
21
|
+
it, a retry after a kill whose answer was lost still reaps what it ran (never a pid since handed to
|
|
22
|
+
another process, or from before a restart), a late redelivery of a finished end is answered ended,
|
|
23
|
+
and a viewer's shell is never made once an end has begun.
|
|
24
|
+
|
|
25
|
+
## 0.16.9
|
|
26
|
+
|
|
27
|
+
- **Windows: a scheduled task counts as gone only when Task Scheduler says so.** An `uninstall`
|
|
28
|
+
whose `schtasks /Delete` failed, and whose `/Query` also failed for another reason (the scheduler
|
|
29
|
+
not answering), took the task as absent and gave the key back. Only a "cannot find" answer counts
|
|
30
|
+
as absent now; anything else is a failure that keeps the task's files and gives nothing back.
|
|
31
|
+
- **A dedicated install undoing itself keeps the administrators' key copy on Windows** when it
|
|
32
|
+
can't tell that copy is its own (`uninstallService`'s new `keepKeyCopy`), as it already kept the
|
|
33
|
+
service home's file.
|
|
34
|
+
|
|
35
|
+
## 0.16.8
|
|
36
|
+
|
|
37
|
+
- **A dedicated install never takes over a service home that holds another computer.** When the
|
|
38
|
+
service home already held a different computer's key, the install restarted that computer under
|
|
39
|
+
the label, left the invoking home's key where it was, and reported a takeover. It is now refused
|
|
40
|
+
before anything is installed, naming the computer it holds.
|
|
41
|
+
- **A copy that isn't there afterwards is no takeover.** When the key the install copied into the
|
|
42
|
+
service home can't be read back as that computer's, the service is uninstalled and the command
|
|
43
|
+
fails, naming the file to check (no credential is deleted: which file this install wrote can't be
|
|
44
|
+
told from one it didn't). The invoking home keeps its key.
|
|
45
|
+
- **`uninstall` says when a service is still stopping after it gives the key back.** The key goes
|
|
46
|
+
back at once, as before (the service's definition is already gone, so a later run could never
|
|
47
|
+
find it again). A stop that failed or is still draining (`launchctl bootout`, `systemctl disable
|
|
48
|
+
--now`) is then waited for up to 30 s, and a service still stopping fails the command with what
|
|
49
|
+
to wait for before running the computer from that home. A service copy that can't be deleted
|
|
50
|
+
after the give-back fails the command instead of leaving the key in two homes behind a success.
|
|
51
|
+
- **Windows: a scheduled task that couldn't be deleted is a failure.** `uninstall` reported it as
|
|
52
|
+
"No host service was installed" and removed its files, so the key went back while the task could
|
|
53
|
+
start again on it. It now fails, keeps the task's files (a later uninstall still finds it), and
|
|
54
|
+
gives nothing back.
|
|
55
|
+
|
|
9
56
|
## 0.16.7
|
|
10
57
|
|
|
11
58
|
- **An undo whose cleanup fails says so.** When a dedicated install undoes itself (its hand-off
|
package/dist/agent/run.js
CHANGED
|
@@ -53,7 +53,7 @@ import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
|
53
53
|
import { verifySignature } from './signature.js';
|
|
54
54
|
import { VENUE_RUN_TOKEN_CAPABILITY, apiForRun, claudeSupportsVenueSessions, claudeVenueCommand, VENUE_PROGRESS, VENUE_SESSION_FEATURE, VENUE_SESSION_FRAME, VENUE_SESSION_OPEN_FRAME, VENUE_GRANT_CHECK_MS, VENUE_EXPIRY_MARGIN_MS, VENUE_GRANT_ENDED_ERROR, VENUE_GRANT_EXPIRED_ERROR, isVenueRun, venueEnv, venueGrantLive, venueGrantRemainingMs, venueRunFromSession, venueWorkdir, } from './venue.js';
|
|
55
55
|
import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
|
|
56
|
-
import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
|
|
56
|
+
import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_END_SESSION_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_END_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
|
|
57
57
|
import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
|
|
58
58
|
import { isWebhookEvent } from '../forward.js';
|
|
59
59
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
@@ -1646,6 +1646,8 @@ export async function runAgentHarness(options) {
|
|
|
1646
1646
|
CODE_THREAD_FOLDERS_CAPABILITY,
|
|
1647
1647
|
codeHost.instanceCapability(),
|
|
1648
1648
|
...(codeHost.agentCapabilities?.() ?? []),
|
|
1649
|
+
// Ending a thread's terminal (`code_thread.end_requested`): whatever the remote switch says.
|
|
1650
|
+
...(codeHost.handleEnd ? [CODE_THREAD_END_SESSION_CAPABILITY] : []),
|
|
1649
1651
|
]
|
|
1650
1652
|
: []),
|
|
1651
1653
|
// Read on every frame, so `remote off` stops being declared at the next one.
|
|
@@ -2081,7 +2083,8 @@ export async function runAgentHarness(options) {
|
|
|
2081
2083
|
if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
|
|
2082
2084
|
frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
|
|
2083
2085
|
frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED ||
|
|
2084
|
-
frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
|
|
2086
|
+
frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED ||
|
|
2087
|
+
frame.event === EVENT_CODE_THREAD_END_REQUESTED) {
|
|
2085
2088
|
if (!codeHost) {
|
|
2086
2089
|
if (!codeHostIgnoredLogged) {
|
|
2087
2090
|
codeHostIgnoredLogged = true;
|
|
@@ -2123,7 +2126,9 @@ export async function runAgentHarness(options) {
|
|
|
2123
2126
|
? codeHost.handleInput(data, frame.delivery_id)
|
|
2124
2127
|
: frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
|
|
2125
2128
|
? (codeHost.handleRestart?.(data, frame.delivery_id) ?? Promise.resolve(false))
|
|
2126
|
-
:
|
|
2129
|
+
: frame.event === EVENT_CODE_THREAD_END_REQUESTED
|
|
2130
|
+
? (codeHost.handleEnd?.(data) ?? Promise.resolve(false))
|
|
2131
|
+
: codeHost.handleApproval(data, frame.delivery_id);
|
|
2127
2132
|
// Acknowledged once handled, never before: a start acked and then
|
|
2128
2133
|
// lost to a crash or a shutdown would leave its thread starting
|
|
2129
2134
|
// forever. A start that ran — or failed, which it reports — is 200;
|
package/dist/codehost/host.d.ts
CHANGED
|
@@ -14,11 +14,13 @@ import type { PtySpawn } from './pty.js';
|
|
|
14
14
|
import type { PairingIdentity } from './relay.js';
|
|
15
15
|
import type { CodeHostSettings } from './settings.js';
|
|
16
16
|
import { CodeTerminals } from './terminals.js';
|
|
17
|
-
import type { AccountEnvResult, ApprovalRequest, CodeAgent, InputRequest, RestartRequest, StartRequest, TmuxRunner } from './terminals.js';
|
|
17
|
+
import type { AccountEnvResult, ApprovalRequest, CodeAgent, EndRequest, InputRequest, RestartRequest, StartRequest, TmuxRunner } from './terminals.js';
|
|
18
18
|
export declare const EVENT_CODE_THREAD_START_REQUESTED = "code_thread.start_requested";
|
|
19
19
|
export declare const EVENT_CODE_THREAD_INPUT_REQUESTED = "code_thread.input_requested";
|
|
20
20
|
export declare const EVENT_CODE_THREAD_APPROVAL_DECIDED = "code_thread.approval_decided";
|
|
21
21
|
export declare const EVENT_CODE_THREAD_RESTART_REQUESTED = "code_thread.restart_requested";
|
|
22
|
+
/** Close a thread's terminal session for good (`CodeTerminals.end`); a CLI without it acks it ignored. */
|
|
23
|
+
export declare const EVENT_CODE_THREAD_END_REQUESTED = "code_thread.end_requested";
|
|
22
24
|
/**
|
|
23
25
|
* Declared in `runtime_status.capabilities` while this harness hosts threads.
|
|
24
26
|
* Versioned with the contract the server relies on (`remote_terminal`, 409
|
|
@@ -60,12 +62,21 @@ export declare const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = "code_thread_skip
|
|
|
60
62
|
* (`CodeTerminals.restart`). The server offers the restart only for a thread whose host said so.
|
|
61
63
|
*/
|
|
62
64
|
export declare const CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY = "code_thread_restart_skip_permissions";
|
|
65
|
+
/**
|
|
66
|
+
* Declared beside {@link CODE_THREADS_CAPABILITY} by a host that handles `code_thread.end_requested`:
|
|
67
|
+
* it closes a thread's windows and what they run, and forgets the terminal (`CodeTerminals.end`).
|
|
68
|
+
* Not tied to the remote switch: ending only stops things on this Mac, never types. The server
|
|
69
|
+
* offers "End session" only for a thread whose host said so.
|
|
70
|
+
*/
|
|
71
|
+
export declare const CODE_THREAD_END_SESSION_CAPABILITY = "code_thread_end_session";
|
|
63
72
|
/** `code_thread.start_requested` data (camelCase per the contract), or `null`. */
|
|
64
73
|
export declare function parseStartRequest(data: Record<string, unknown>): StartRequest | null;
|
|
65
74
|
/** `code_thread.input_requested` data, or `null`. */
|
|
66
75
|
export declare function parseInputRequest(data: Record<string, unknown>): InputRequest | null;
|
|
67
76
|
/** `code_thread.restart_requested` data, or `null`. */
|
|
68
77
|
export declare function parseRestartRequest(data: Record<string, unknown>): RestartRequest | null;
|
|
78
|
+
/** `code_thread.end_requested` data, or `null`. */
|
|
79
|
+
export declare function parseEndRequest(data: Record<string, unknown>): EndRequest | null;
|
|
69
80
|
/** What the harness needs from a host; tests fake it. */
|
|
70
81
|
export interface CodeHostLike {
|
|
71
82
|
start(): Promise<void>;
|
|
@@ -80,6 +91,8 @@ export interface CodeHostLike {
|
|
|
80
91
|
handleApproval(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
|
|
81
92
|
/** `code_thread.restart_requested`; a host without it acks the event as ignored. */
|
|
82
93
|
handleRestart?(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
|
|
94
|
+
/** `code_thread.end_requested`; a host without it acks the event as ignored (and never declares the capability). */
|
|
95
|
+
handleEnd?(data: Record<string, unknown>): Promise<boolean>;
|
|
83
96
|
latestStats(): HostStats | undefined;
|
|
84
97
|
/** The owner's remote-terminal switch as it is on disk now. */
|
|
85
98
|
remoteTerminalAllowed(): boolean;
|
|
@@ -127,8 +140,8 @@ export declare function parseApprovalDecision(data: Record<string, unknown>): Ap
|
|
|
127
140
|
* server hands the reply or decision back instead of counting it delivered).
|
|
128
141
|
*/
|
|
129
142
|
export declare class RefusedDeliveryError extends Error {
|
|
130
|
-
readonly reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open' | 'session_unknown' | 'login_changed' | 'login_unavailable' | 'restart_expired' | 'restart_unconfirmed' | 'agent_exited';
|
|
131
|
-
constructor(reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open' | 'session_unknown' | 'login_changed' | 'login_unavailable' | 'restart_expired' | 'restart_unconfirmed' | 'agent_exited');
|
|
143
|
+
readonly reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open' | 'session_unknown' | 'login_changed' | 'login_unavailable' | 'restart_expired' | 'end_expired' | 'restart_unconfirmed' | 'agent_exited';
|
|
144
|
+
constructor(reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open' | 'session_unknown' | 'login_changed' | 'login_unavailable' | 'restart_expired' | 'end_expired' | 'restart_unconfirmed' | 'agent_exited');
|
|
132
145
|
}
|
|
133
146
|
export declare class UnreportedOutcomeError extends Error {
|
|
134
147
|
constructor(codeThreadId: string);
|
|
@@ -171,6 +184,8 @@ export interface CodeHostOptions {
|
|
|
171
184
|
paused?: () => boolean;
|
|
172
185
|
tmux?: TmuxRunner;
|
|
173
186
|
spawnPty?: () => PtySpawn | undefined;
|
|
187
|
+
/** What an end signals what a closed window left (`CodeTerminalsOptions.signalGroup`). */
|
|
188
|
+
signalGroup?: (pid: number, signal: NodeJS.Signals | 0) => boolean;
|
|
174
189
|
events?: CodeThreadEventSink;
|
|
175
190
|
relay?: boolean;
|
|
176
191
|
localServer?: boolean;
|
|
@@ -319,6 +334,12 @@ export declare class CodeHost implements CodeHostLike {
|
|
|
319
334
|
* A redelivery of one already done is acknowledged as done, without restarting again.
|
|
320
335
|
*/
|
|
321
336
|
handleRestart(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
|
|
337
|
+
/**
|
|
338
|
+
* Close the thread's terminal session for good (`CodeTerminals.end`): true once it is gone, whether
|
|
339
|
+
* it was here or not, so a redelivery is answered the same. Refused when it came too late; a tmux
|
|
340
|
+
* that didn't answer is retried (503).
|
|
341
|
+
*/
|
|
342
|
+
handleEnd(data: Record<string, unknown>): Promise<boolean>;
|
|
322
343
|
/** The terminal's menu state (`menuState`) once a hook that is still deciding has had a moment to finish. */
|
|
323
344
|
private menuSettled;
|
|
324
345
|
/**
|
package/dist/codehost/host.js
CHANGED
|
@@ -30,6 +30,8 @@ export const EVENT_CODE_THREAD_START_REQUESTED = 'code_thread.start_requested';
|
|
|
30
30
|
export const EVENT_CODE_THREAD_INPUT_REQUESTED = 'code_thread.input_requested';
|
|
31
31
|
export const EVENT_CODE_THREAD_APPROVAL_DECIDED = 'code_thread.approval_decided';
|
|
32
32
|
export const EVENT_CODE_THREAD_RESTART_REQUESTED = 'code_thread.restart_requested';
|
|
33
|
+
/** Close a thread's terminal session for good (`CodeTerminals.end`); a CLI without it acks it ignored. */
|
|
34
|
+
export const EVENT_CODE_THREAD_END_REQUESTED = 'code_thread.end_requested';
|
|
33
35
|
/**
|
|
34
36
|
* Declared in `runtime_status.capabilities` while this harness hosts threads.
|
|
35
37
|
* Versioned with the contract the server relies on (`remote_terminal`, 409
|
|
@@ -71,6 +73,13 @@ export const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_skip_permiss
|
|
|
71
73
|
* (`CodeTerminals.restart`). The server offers the restart only for a thread whose host said so.
|
|
72
74
|
*/
|
|
73
75
|
export const CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_restart_skip_permissions';
|
|
76
|
+
/**
|
|
77
|
+
* Declared beside {@link CODE_THREADS_CAPABILITY} by a host that handles `code_thread.end_requested`:
|
|
78
|
+
* it closes a thread's windows and what they run, and forgets the terminal (`CodeTerminals.end`).
|
|
79
|
+
* Not tied to the remote switch: ending only stops things on this Mac, never types. The server
|
|
80
|
+
* offers "End session" only for a thread whose host said so.
|
|
81
|
+
*/
|
|
82
|
+
export const CODE_THREAD_END_SESSION_CAPABILITY = 'code_thread_end_session';
|
|
74
83
|
function text(value) {
|
|
75
84
|
return typeof value === 'string' ? value : undefined;
|
|
76
85
|
}
|
|
@@ -118,6 +127,14 @@ export function parseRestartRequest(data) {
|
|
|
118
127
|
...(requestedAtMs !== undefined ? { requestedAtMs } : {}),
|
|
119
128
|
};
|
|
120
129
|
}
|
|
130
|
+
/** `code_thread.end_requested` data, or `null`. */
|
|
131
|
+
export function parseEndRequest(data) {
|
|
132
|
+
const { codeThreadId, terminalId } = data;
|
|
133
|
+
if (!isValidId(codeThreadId) || !isValidId(terminalId))
|
|
134
|
+
return null;
|
|
135
|
+
const requestedAtMs = typeof data.requestedAtMs === 'number' && Number.isFinite(data.requestedAtMs) ? data.requestedAtMs : undefined;
|
|
136
|
+
return { codeThreadId, terminalId, ...(requestedAtMs !== undefined ? { requestedAtMs } : {}) };
|
|
137
|
+
}
|
|
121
138
|
/** `code_thread.approval_decided` data, or `null`. */
|
|
122
139
|
export function parseApprovalDecision(data) {
|
|
123
140
|
const { codeThreadId, terminalId, approvalId, decision } = data;
|
|
@@ -216,6 +233,7 @@ export class CodeHost {
|
|
|
216
233
|
agentInstalled: (agent) => resolvesOnPath(AGENT_BINARIES[agent], this.env),
|
|
217
234
|
authorized: () => this.authorized(),
|
|
218
235
|
remoteControlAllowed: () => this.settings().allowRemoteTerminal,
|
|
236
|
+
signalGroup: options.signalGroup,
|
|
219
237
|
log: this.log,
|
|
220
238
|
});
|
|
221
239
|
}
|
|
@@ -547,11 +565,15 @@ export class CodeHost {
|
|
|
547
565
|
// acknowledged as done, never refused (the server would then say the agent still asks).
|
|
548
566
|
if (event === EVENT_CODE_THREAD_RESTART_REQUESTED)
|
|
549
567
|
return true;
|
|
568
|
+
// Ending only stops what runs here, never types: taken whatever the remote switch says.
|
|
569
|
+
if (event === EVENT_CODE_THREAD_END_REQUESTED)
|
|
570
|
+
return true;
|
|
550
571
|
return settings.allowRemoteTerminal;
|
|
551
572
|
}
|
|
552
573
|
refusal(event) {
|
|
553
|
-
if (event === EVENT_CODE_THREAD_START_REQUESTED || !this.authorized())
|
|
574
|
+
if (event === EVENT_CODE_THREAD_START_REQUESTED || event === EVENT_CODE_THREAD_END_REQUESTED || !this.authorized()) {
|
|
554
575
|
return undefined;
|
|
576
|
+
}
|
|
555
577
|
const settings = this.settings();
|
|
556
578
|
return settings.allowRemoteTerminal ? undefined : 'remote_terminal_disabled';
|
|
557
579
|
}
|
|
@@ -679,6 +701,24 @@ export class CodeHost {
|
|
|
679
701
|
this.retryLater(outcome.release ? deliveryId : undefined, request.codeThreadId);
|
|
680
702
|
return false;
|
|
681
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Close the thread's terminal session for good (`CodeTerminals.end`): true once it is gone, whether
|
|
706
|
+
* it was here or not, so a redelivery is answered the same. Refused when it came too late; a tmux
|
|
707
|
+
* that didn't answer is retried (503).
|
|
708
|
+
*/
|
|
709
|
+
async handleEnd(data) {
|
|
710
|
+
const request = parseEndRequest(data);
|
|
711
|
+
if (!request)
|
|
712
|
+
return false;
|
|
713
|
+
const outcome = {};
|
|
714
|
+
if (await this.terminals.end(request, outcome))
|
|
715
|
+
return true;
|
|
716
|
+
if (outcome.refused)
|
|
717
|
+
throw new RefusedDeliveryError(outcome.refused);
|
|
718
|
+
if (outcome.retry)
|
|
719
|
+
this.retryLater(undefined, request.codeThreadId);
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
682
722
|
/** The terminal's menu state (`menuState`) once a hook that is still deciding has had a moment to finish. */
|
|
683
723
|
async menuSettled(terminalId) {
|
|
684
724
|
if (!isValidId(terminalId))
|
|
@@ -162,6 +162,31 @@ export interface RestartRequest {
|
|
|
162
162
|
/** Relaunched without its permission prompts (only an explicit `true`; see {@link agentCommandLine}). */
|
|
163
163
|
skipPermissions: boolean;
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* How long an end may wait to reach this Mac: one that arrives later is refused (`end_expired`), so a
|
|
167
|
+
* Mac that wakes up the next day never closes a terminal someone has since gone back to. The server
|
|
168
|
+
* holds the same limit (`END_SESSION_MAX_AGE`).
|
|
169
|
+
*/
|
|
170
|
+
export declare const END_MAX_AGE_MS: number;
|
|
171
|
+
/** After its windows close: how long what they ran has to exit on SIGTERM before it is killed. */
|
|
172
|
+
export declare const END_KILL_GRACE_MS = 2000;
|
|
173
|
+
/** `code_thread.end_requested`: close the thread's terminal session for good. */
|
|
174
|
+
export interface EndRequest {
|
|
175
|
+
codeThreadId: string;
|
|
176
|
+
terminalId: string;
|
|
177
|
+
/** When it was asked (server clock): refused past {@link END_MAX_AGE_MS}. Absent: never stale. */
|
|
178
|
+
requestedAtMs?: number;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The mark an end leaves (`state/<terminal>.ended`): that terminal is never started or attached again,
|
|
182
|
+
* whatever comes later (a start whose first ack was lost and is delivered again, a viewer racing the end).
|
|
183
|
+
*/
|
|
184
|
+
export declare function endedPath(terminalId: string, env: NodeJS.ProcessEnv): string;
|
|
185
|
+
/** Why an end did nothing (`refused`), or that it should come again (`retry`: tmux didn't answer in time). */
|
|
186
|
+
export interface EndOutcome {
|
|
187
|
+
refused?: 'end_expired';
|
|
188
|
+
retry?: boolean;
|
|
189
|
+
}
|
|
165
190
|
export interface ApprovalRequest {
|
|
166
191
|
codeThreadId: string;
|
|
167
192
|
terminalId: string;
|
|
@@ -339,6 +364,11 @@ export interface CodeTerminalsOptions {
|
|
|
339
364
|
agentInstalled?: (agent: InstalledAgent) => boolean;
|
|
340
365
|
/** Seam for tests: the timers who-sizes-the-window settles with (`sizing.ts`). */
|
|
341
366
|
setTimer?: (fn: () => void, ms: number) => () => void;
|
|
367
|
+
/**
|
|
368
|
+
* Seam for tests: signal the process group `pid` leads (`kill(-pid, signal)`; `0` only asks whether
|
|
369
|
+
* it still has a process). Returns whether it reached one. Default: `process.kill`.
|
|
370
|
+
*/
|
|
371
|
+
signalGroup?: (pid: number, signal: NodeJS.Signals | 0) => boolean;
|
|
342
372
|
}
|
|
343
373
|
/** What the thread says while Claude Code waits on its trust dialog. */
|
|
344
374
|
export declare const TRUST_PROMPT_NOTE = "Claude Code is asking whether to trust this folder before it starts. Answer in the terminal.";
|
|
@@ -363,6 +393,17 @@ export declare class CodeTerminals {
|
|
|
363
393
|
private lastListUncertain;
|
|
364
394
|
/** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
|
|
365
395
|
private panePids;
|
|
396
|
+
/** Every thread window's pane process at the last `adopt`, by window name: what an end makes sure exits. */
|
|
397
|
+
private windowPids;
|
|
398
|
+
/** Per terminal: how to close each viewer attached to it now (an end closes them before its windows). */
|
|
399
|
+
private readonly viewers;
|
|
400
|
+
/** Terminals an end has begun on (set before its first await): no viewer or shell window joins them. */
|
|
401
|
+
private readonly ending;
|
|
402
|
+
/**
|
|
403
|
+
* Per terminal: its window lifecycle (a viewer's shell being made, an end), one at a time. A shell is
|
|
404
|
+
* made only while no end has begun, and an end runs only once a shell being made exists to close.
|
|
405
|
+
*/
|
|
406
|
+
private readonly lifecycle;
|
|
366
407
|
/** Who sizes each window, and what its viewers are told (`sizing.ts`). */
|
|
367
408
|
readonly sizing: TerminalSizing;
|
|
368
409
|
private readonly log;
|
|
@@ -384,6 +425,8 @@ export declare class CodeTerminals {
|
|
|
384
425
|
* terminal never interleave into "first, second, Enter, Enter".
|
|
385
426
|
*/
|
|
386
427
|
private serialize;
|
|
428
|
+
/** Run `work` after every earlier job queued in `queues` for this terminal. */
|
|
429
|
+
private serializeOn;
|
|
387
430
|
/**
|
|
388
431
|
* Mark a thread's worktree lane as in use (`.last-used`, the marker every
|
|
389
432
|
* {@link WorktreeManager} prunes by), so a long-lived terminal's checkout is
|
|
@@ -463,6 +506,48 @@ export declare class CodeTerminals {
|
|
|
463
506
|
* (`session_unknown`). An agent that exited into its shell is relaunched all the same.
|
|
464
507
|
* Serialized with the thread's replies, so it never lands between a paste and its Enter.
|
|
465
508
|
*/
|
|
509
|
+
/**
|
|
510
|
+
* End the thread's terminal session for good (`code_thread.end_requested`): every viewer attached to
|
|
511
|
+
* it is closed, then its windows (tmux hangs up what they run), then whatever of their process groups
|
|
512
|
+
* is still there gets SIGTERM, and SIGKILL after {@link END_KILL_GRACE_MS}. Its windows were the
|
|
513
|
+
* only record of it (`adopt` reads them), so it is never adopted again and drops out of `list()`.
|
|
514
|
+
* Its folder is left as it is: the work in it is the owner's.
|
|
515
|
+
*
|
|
516
|
+
* Idempotent: a terminal this host doesn't have (ended already, closed in tmux, never here) is
|
|
517
|
+
* ended, and so true. False with `outcome.refused` when it came too late, with `outcome.retry` when
|
|
518
|
+
* tmux didn't answer in time (nothing known either way: asked again). Queued behind any reply or
|
|
519
|
+
* restart in flight for it, and after a start of it still running.
|
|
520
|
+
*/
|
|
521
|
+
end(request: EndRequest, outcome?: EndOutcome): Promise<boolean>;
|
|
522
|
+
/** Whether an end has begun on, or finished with, this terminal (`ending`, its `.ended` mark). */
|
|
523
|
+
private ended;
|
|
524
|
+
/** Whether an earlier end of this terminal left its `.ended` mark. */
|
|
525
|
+
private hasEndMark;
|
|
526
|
+
/** This terminal's `.ended` mark as an earlier end left it, or `undefined`. */
|
|
527
|
+
private readMark;
|
|
528
|
+
/**
|
|
529
|
+
* The panes an earlier, unfinished end of this terminal left to reap: only from this boot (after a
|
|
530
|
+
* restart of the Mac every pid is someone else's). Each is checked again before it is signaled.
|
|
531
|
+
*/
|
|
532
|
+
private pendingReap;
|
|
533
|
+
private markEnded;
|
|
534
|
+
private endNow;
|
|
535
|
+
/**
|
|
536
|
+
* What a closed window ran and didn't exit on its hangup (a process ignoring SIGHUP, a child in its
|
|
537
|
+
* group): SIGTERM, then SIGKILL past {@link END_KILL_GRACE_MS}. Only each pane's own process group:
|
|
538
|
+
* never a process that left it on purpose (`setsid`, a daemon it started).
|
|
539
|
+
*/
|
|
540
|
+
private reapGroups;
|
|
541
|
+
/** When each of `pids` started (`paneStarts`); empty when `ps` couldn't say. */
|
|
542
|
+
private startsOf;
|
|
543
|
+
/**
|
|
544
|
+
* Of panes an earlier attempt saved, those whose process group is still this terminal's. A group
|
|
545
|
+
* outlives its leader, and a pid is never handed out while a group of that id exists: so one whose
|
|
546
|
+
* 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.
|
|
549
|
+
*/
|
|
550
|
+
private stillOurs;
|
|
466
551
|
restart(request: RestartRequest, outcome?: RestartOutcome, hooks?: RestartHooks): Promise<boolean>;
|
|
467
552
|
/** Once every typing job and restart already queued for the terminal has finished (never rejects). */
|
|
468
553
|
settled(terminalId: string): Promise<void>;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { execFile } from 'node:child_process';
|
|
23
23
|
import { randomBytes } from 'node:crypto';
|
|
24
|
-
import { closeSync, openSync, utimesSync } from 'node:fs';
|
|
24
|
+
import { closeSync, existsSync, openSync, readFileSync, utimesSync } from 'node:fs';
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { shellQuote } from '../agent/shell.js';
|
|
@@ -33,12 +33,13 @@ import { claudeConfigDir, claudeTrusts, trustClaudeWorkspace } from './claude_tr
|
|
|
33
33
|
import { event } from './events.js';
|
|
34
34
|
import { readHistory } from './history.js';
|
|
35
35
|
import { loginOf, readSession } from './session.js';
|
|
36
|
+
import { codeHostDir, writePrivateJson } from './settings.js';
|
|
36
37
|
import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
|
|
37
38
|
import { MAX_OPEN_PTYS } from './pty.js';
|
|
38
39
|
import { beginLaunch, claudeSessionSince, claudeTranscripts, currentLaunch, isReady, LAUNCH_ENV, launchState, markReady, restoreLaunch, } from './ready.js';
|
|
39
40
|
import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
|
|
40
41
|
import { noteInputTakeover } from './sizing.js';
|
|
41
|
-
import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, respawnPaneArgs, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
|
|
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';
|
|
42
43
|
export const CODE_AGENTS = ['claude_code', 'codex', 'shell'];
|
|
43
44
|
export const GOAL_ENV = 'PROHOST_CODE_THREAD_GOAL';
|
|
44
45
|
/** Window option set when the agent exits and its window becomes a shell. */
|
|
@@ -109,6 +110,31 @@ export const RESTART_STOP_LATER_ATTEMPTS = 4;
|
|
|
109
110
|
export const RESTART_STOP_LATER_PAUSE_MS = 15_000;
|
|
110
111
|
/** How old a restart may be when it reaches this Mac (the server's `RESTART_MAX_AGE`). */
|
|
111
112
|
export const RESTART_MAX_AGE_MS = 10 * 60_000;
|
|
113
|
+
/**
|
|
114
|
+
* How long an end may wait to reach this Mac: one that arrives later is refused (`end_expired`), so a
|
|
115
|
+
* Mac that wakes up the next day never closes a terminal someone has since gone back to. The server
|
|
116
|
+
* holds the same limit (`END_SESSION_MAX_AGE`).
|
|
117
|
+
*/
|
|
118
|
+
export const END_MAX_AGE_MS = 10 * 60_000;
|
|
119
|
+
/** After its windows close: how long what they ran has to exit on SIGTERM before it is killed. */
|
|
120
|
+
export const END_KILL_GRACE_MS = 2_000;
|
|
121
|
+
/** How far apart two readings of one process's start may be (`ps` gives whole seconds of elapsed time). */
|
|
122
|
+
const PANE_START_SLACK_MS = 2_000;
|
|
123
|
+
/** Two boot times this close are the same boot (`os.uptime` drifts with sleep and rounding). */
|
|
124
|
+
const BOOT_FENCE_MS = 60_000;
|
|
125
|
+
/** When this Mac last booted, in ms. */
|
|
126
|
+
function bootedAtMs() {
|
|
127
|
+
return Date.now() - os.uptime() * 1000;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The mark an end leaves (`state/<terminal>.ended`): that terminal is never started or attached again,
|
|
131
|
+
* whatever comes later (a start whose first ack was lost and is delivered again, a viewer racing the end).
|
|
132
|
+
*/
|
|
133
|
+
export function endedPath(terminalId, env) {
|
|
134
|
+
if (!isValidId(terminalId))
|
|
135
|
+
throw new Error('invalid terminal id');
|
|
136
|
+
return path.join(codeHostDir(env), 'state', `${terminalId}.ended`);
|
|
137
|
+
}
|
|
112
138
|
/**
|
|
113
139
|
* Whether an event post's status settles it (see {@link StartResult}): the
|
|
114
140
|
* server applied it (2xx) or will refuse it the same way every time (404, 409,
|
|
@@ -169,6 +195,17 @@ export function agentCommandLine(agent, options) {
|
|
|
169
195
|
}
|
|
170
196
|
return `PROHOST_INVOCATION_PROTOCOL=0 ${claude} ${shellQuote(settings, 'darwin')}${goal}; ${after}`;
|
|
171
197
|
}
|
|
198
|
+
/** `kill(-pid, signal)`: whether the group still had a process to reach. */
|
|
199
|
+
function signalProcessGroup(pid, signal) {
|
|
200
|
+
try {
|
|
201
|
+
process.kill(-pid, signal);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// ESRCH: nothing left in it. EPERM: not ours, never touched.
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
172
209
|
/**
|
|
173
210
|
* A phrase as a first-run screen shows it: Claude Code wraps its own text inside a bordered box (a
|
|
174
211
|
* narrow window breaks it anywhere, and `capture-pane -J` joins only tmux's wraps), so words may be
|
|
@@ -233,6 +270,17 @@ export class CodeTerminals {
|
|
|
233
270
|
lastListUncertain = false;
|
|
234
271
|
/** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
|
|
235
272
|
panePids = new Map();
|
|
273
|
+
/** Every thread window's pane process at the last `adopt`, by window name: what an end makes sure exits. */
|
|
274
|
+
windowPids = new Map();
|
|
275
|
+
/** Per terminal: how to close each viewer attached to it now (an end closes them before its windows). */
|
|
276
|
+
viewers = new Map();
|
|
277
|
+
/** Terminals an end has begun on (set before its first await): no viewer or shell window joins them. */
|
|
278
|
+
ending = new Set();
|
|
279
|
+
/**
|
|
280
|
+
* Per terminal: its window lifecycle (a viewer's shell being made, an end), one at a time. A shell is
|
|
281
|
+
* made only while no end has begun, and an end runs only once a shell being made exists to close.
|
|
282
|
+
*/
|
|
283
|
+
lifecycle = new Map();
|
|
236
284
|
/** Who sizes each window, and what its viewers are told (`sizing.ts`). */
|
|
237
285
|
sizing;
|
|
238
286
|
log;
|
|
@@ -284,13 +332,17 @@ export class CodeTerminals {
|
|
|
284
332
|
* terminal never interleave into "first, second, Enter, Enter".
|
|
285
333
|
*/
|
|
286
334
|
serialize(terminalId, work) {
|
|
287
|
-
|
|
335
|
+
return this.serializeOn(this.typing, terminalId, work);
|
|
336
|
+
}
|
|
337
|
+
/** Run `work` after every earlier job queued in `queues` for this terminal. */
|
|
338
|
+
serializeOn(queues, terminalId, work) {
|
|
339
|
+
const previous = queues.get(terminalId) ?? Promise.resolve();
|
|
288
340
|
const next = previous.then(work, work);
|
|
289
341
|
const tail = next.catch(() => undefined);
|
|
290
|
-
|
|
342
|
+
queues.set(terminalId, tail);
|
|
291
343
|
void tail.then(() => {
|
|
292
|
-
if (
|
|
293
|
-
|
|
344
|
+
if (queues.get(terminalId) === tail)
|
|
345
|
+
queues.delete(terminalId);
|
|
294
346
|
});
|
|
295
347
|
return next;
|
|
296
348
|
}
|
|
@@ -355,13 +407,17 @@ export class CodeTerminals {
|
|
|
355
407
|
if (!listed.ok) {
|
|
356
408
|
// No server or no session: nothing survived, and nothing is wrong.
|
|
357
409
|
this.records.clear();
|
|
410
|
+
this.windowPids.clear();
|
|
358
411
|
return 0;
|
|
359
412
|
}
|
|
360
413
|
const names = new Set();
|
|
361
414
|
const found = new Map();
|
|
362
415
|
const panes = new Map();
|
|
416
|
+
const windowPids = new Map();
|
|
363
417
|
for (const window of parseListWindows(listed.stdout)) {
|
|
364
418
|
names.add(window.name);
|
|
419
|
+
if (window.panePid)
|
|
420
|
+
windowPids.set(window.name, window.panePid);
|
|
365
421
|
// Left by an earlier daemon, perhaps still holding its shell: released in time all the same.
|
|
366
422
|
this.sizing.adopted(window.name);
|
|
367
423
|
if (window.meta && window.name === windowName(window.meta.terminalId, 'agent')) {
|
|
@@ -378,6 +434,7 @@ export class CodeTerminals {
|
|
|
378
434
|
for (const [id, record] of found)
|
|
379
435
|
this.records.set(id, record);
|
|
380
436
|
this.panePids = panes;
|
|
437
|
+
this.windowPids = windowPids;
|
|
381
438
|
// Once per daemon start: the windows an older build left behind are all there now. Started by
|
|
382
439
|
// the first call; every call waits for it, so none returns before those windows are marked. A
|
|
383
440
|
// pass that fails (a keychain still locked right after login) never fails an adopt: it is
|
|
@@ -471,6 +528,11 @@ export class CodeTerminals {
|
|
|
471
528
|
}
|
|
472
529
|
async startOnce(request) {
|
|
473
530
|
const reportStart = async () => isDefinitive(await this.options.events.post(request.codeThreadId, event('start', { terminalId: request.terminalId, agent: request.agent, branch: request.branch ?? null }, this.now())));
|
|
531
|
+
if (this.ended(request.terminalId)) {
|
|
532
|
+
// Ended from ProhostAI: a start delivered again (its first ack was lost) never brings it back.
|
|
533
|
+
this.log(` ◦ code thread ${request.codeThreadId.slice(0, 12)}: not started again, its terminal was ended`);
|
|
534
|
+
return { ok: false, reason: 'terminal ended', reported: true };
|
|
535
|
+
}
|
|
474
536
|
await this.adopt();
|
|
475
537
|
// A redelivery (the first outcome never reached the server) reports again.
|
|
476
538
|
if (this.records.has(request.terminalId))
|
|
@@ -655,6 +717,210 @@ export class CodeTerminals {
|
|
|
655
717
|
* (`session_unknown`). An agent that exited into its shell is relaunched all the same.
|
|
656
718
|
* Serialized with the thread's replies, so it never lands between a paste and its Enter.
|
|
657
719
|
*/
|
|
720
|
+
/**
|
|
721
|
+
* End the thread's terminal session for good (`code_thread.end_requested`): every viewer attached to
|
|
722
|
+
* it is closed, then its windows (tmux hangs up what they run), then whatever of their process groups
|
|
723
|
+
* is still there gets SIGTERM, and SIGKILL after {@link END_KILL_GRACE_MS}. Its windows were the
|
|
724
|
+
* only record of it (`adopt` reads them), so it is never adopted again and drops out of `list()`.
|
|
725
|
+
* Its folder is left as it is: the work in it is the owner's.
|
|
726
|
+
*
|
|
727
|
+
* Idempotent: a terminal this host doesn't have (ended already, closed in tmux, never here) is
|
|
728
|
+
* ended, and so true. False with `outcome.refused` when it came too late, with `outcome.retry` when
|
|
729
|
+
* tmux didn't answer in time (nothing known either way: asked again). Queued behind any reply or
|
|
730
|
+
* restart in flight for it, and after a start of it still running.
|
|
731
|
+
*/
|
|
732
|
+
async end(request, outcome = {}) {
|
|
733
|
+
if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId))
|
|
734
|
+
return false;
|
|
735
|
+
// Before any await: an attach that hasn't registered its viewer yet sees it and stops.
|
|
736
|
+
this.ending.add(request.terminalId);
|
|
737
|
+
try {
|
|
738
|
+
await this.starting.get(request.terminalId)?.catch(() => undefined);
|
|
739
|
+
// After any shell a viewer is making for it (`lifecycle`), then any reply or restart (`typing`).
|
|
740
|
+
return await this.serializeOn(this.lifecycle, request.terminalId, () => this.serialize(request.terminalId, () => this.endNow(request, outcome)));
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
this.ending.delete(request.terminalId);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/** Whether an end has begun on, or finished with, this terminal (`ending`, its `.ended` mark). */
|
|
747
|
+
ended(terminalId) {
|
|
748
|
+
return this.ending.has(terminalId) || this.hasEndMark(terminalId);
|
|
749
|
+
}
|
|
750
|
+
/** Whether an earlier end of this terminal left its `.ended` mark. */
|
|
751
|
+
hasEndMark(terminalId) {
|
|
752
|
+
try {
|
|
753
|
+
return existsSync(endedPath(terminalId, { PROHOST_HOME: this.options.home }));
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** This terminal's `.ended` mark as an earlier end left it, or `undefined`. */
|
|
760
|
+
readMark(terminalId) {
|
|
761
|
+
try {
|
|
762
|
+
const mark = JSON.parse(readFileSync(endedPath(terminalId, { PROHOST_HOME: this.options.home }), 'utf8'));
|
|
763
|
+
return {
|
|
764
|
+
bootMs: typeof mark.bootMs === 'number' ? mark.bootMs : 0,
|
|
765
|
+
completed: mark.completed === true,
|
|
766
|
+
pids: Array.isArray(mark.pids)
|
|
767
|
+
? mark.pids.filter((entry) => typeof entry === 'object' && entry !== null && Number.isInteger(entry.pid) && entry.pid > 1)
|
|
768
|
+
: [],
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* The panes an earlier, unfinished end of this terminal left to reap: only from this boot (after a
|
|
777
|
+
* restart of the Mac every pid is someone else's). Each is checked again before it is signaled.
|
|
778
|
+
*/
|
|
779
|
+
pendingReap(terminalId) {
|
|
780
|
+
const mark = this.readMark(terminalId);
|
|
781
|
+
if (!mark || mark.completed || Math.abs(mark.bootMs - bootedAtMs()) > BOOT_FENCE_MS)
|
|
782
|
+
return [];
|
|
783
|
+
return mark.pids;
|
|
784
|
+
}
|
|
785
|
+
markEnded(request, pids, completed) {
|
|
786
|
+
writePrivateJson(endedPath(request.terminalId, { PROHOST_HOME: this.options.home }), {
|
|
787
|
+
codeThreadId: request.codeThreadId,
|
|
788
|
+
endedAtMs: this.now(),
|
|
789
|
+
bootMs: bootedAtMs(),
|
|
790
|
+
completed,
|
|
791
|
+
pids,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
async endNow(request, outcome) {
|
|
795
|
+
const short = request.codeThreadId.slice(0, 12);
|
|
796
|
+
// Too late to end a terminal someone may have gone back to; but one this host already ended (its
|
|
797
|
+
// 200 was lost, and the delivery comes again later) has nothing to go back to: finished, and said so.
|
|
798
|
+
// Only an end that finished: one that stopped partway (tmux didn't answer) is held to the age limit.
|
|
799
|
+
const endedAlready = this.readMark(request.terminalId)?.completed === true;
|
|
800
|
+
if (!endedAlready && request.requestedAtMs !== undefined && this.now() - request.requestedAtMs > END_MAX_AGE_MS) {
|
|
801
|
+
this.log(` ! code thread ${short}: terminal not ended, the request arrived too late`);
|
|
802
|
+
outcome.refused = 'end_expired';
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
await this.adopt();
|
|
806
|
+
if (this.lastListUncertain) {
|
|
807
|
+
outcome.retry = true;
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
const record = this.records.get(request.terminalId);
|
|
811
|
+
// Another thread's terminal under this id is never touched, nor marked.
|
|
812
|
+
if (record && record.codeThreadId !== request.codeThreadId) {
|
|
813
|
+
this.log(` ◦ code thread ${short}: terminal already ended`);
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
// Its windows: the record's, and a shell left without its agent window (the agent's closed some
|
|
817
|
+
// other way, or an earlier end got only that far): `adopt` keeps no record for a shell alone.
|
|
818
|
+
const windows = record ? [...record.windows] : [];
|
|
819
|
+
if (!windows.includes('shell') && this.windowPids.has(windowName(request.terminalId, 'shell')))
|
|
820
|
+
windows.push('shell');
|
|
821
|
+
// The shell first, the agent window last: the agent window is the record (`adopt`), so one kill that
|
|
822
|
+
// tmux didn't answer leaves it there for the retry to find, with the shell included if it's still up.
|
|
823
|
+
const order = [...windows.filter((w) => w !== 'agent'), ...windows.filter((w) => w === 'agent')];
|
|
824
|
+
const names = order.map((window) => windowName(request.terminalId, window));
|
|
825
|
+
// What to make sure exits: these windows' panes, as tmux lists them now, and what an earlier attempt
|
|
826
|
+
// closed but didn't finish reaping (its kill went through, then tmux's answer timed out). Each pane is
|
|
827
|
+
// saved with when its process started, so a later attempt never signals a pid handed to something else.
|
|
828
|
+
const listed = names.map((name) => this.windowPids.get(name)).filter((pid) => pid !== undefined);
|
|
829
|
+
const starts = await this.startsOf(listed);
|
|
830
|
+
const fresh = listed.map((pid) => ({ pid, ...(starts.has(pid) ? { startedAtMs: starts.get(pid) } : {}) }));
|
|
831
|
+
const saved = this.pendingReap(request.terminalId).filter((entry) => !listed.includes(entry.pid));
|
|
832
|
+
const pids = [...fresh, ...saved];
|
|
833
|
+
// Marked first, whether or not it is here, with those pids: whatever happens to the kills below,
|
|
834
|
+
// nothing starts or attaches it again (a start delivered late, or again after a lost ack), and a
|
|
835
|
+
// retry still reaps what they ran.
|
|
836
|
+
try {
|
|
837
|
+
this.markEnded(request, pids, false);
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
this.log(` ! code thread ${short}: couldn't record its end (${err instanceof Error ? err.message : String(err)})`);
|
|
841
|
+
outcome.retry = true;
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
for (const closeViewer of [...(this.viewers.get(request.terminalId) ?? [])])
|
|
845
|
+
closeViewer();
|
|
846
|
+
this.viewers.delete(request.terminalId);
|
|
847
|
+
for (const name of names) {
|
|
848
|
+
const killed = await this.tmux(killWindowArgs(name));
|
|
849
|
+
// A window already gone is what was asked; one tmux didn't answer for may still be there.
|
|
850
|
+
if (!killed.ok && killed.uncertain) {
|
|
851
|
+
outcome.retry = true;
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
this.windowPids.delete(name);
|
|
855
|
+
}
|
|
856
|
+
this.records.delete(request.terminalId);
|
|
857
|
+
this.panePids.delete(request.terminalId);
|
|
858
|
+
this.lastInterrupt.delete(request.terminalId);
|
|
859
|
+
await this.reapGroups(fresh.map((entry) => entry.pid), await this.stillOurs(saved));
|
|
860
|
+
try {
|
|
861
|
+
// Finished and reaped: nothing left for a retry to signal, and a late redelivery is answered ended.
|
|
862
|
+
this.markEnded(request, [], true);
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
/* the mark stays with its pids; only a recent one is ever reaped again */
|
|
866
|
+
}
|
|
867
|
+
this.log(` ■ code thread ${short}: terminal ${names.length > 0 ? 'ended' : 'already ended'}`);
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* What a closed window ran and didn't exit on its hangup (a process ignoring SIGHUP, a child in its
|
|
872
|
+
* group): SIGTERM, then SIGKILL past {@link END_KILL_GRACE_MS}. Only each pane's own process group:
|
|
873
|
+
* never a process that left it on purpose (`setsid`, a daemon it started).
|
|
874
|
+
*/
|
|
875
|
+
async reapGroups(fresh, saved = []) {
|
|
876
|
+
const signal = this.options.signalGroup ?? signalProcessGroup;
|
|
877
|
+
const left = [...new Set([...fresh, ...saved])].filter((pid) => signal(pid, 'SIGTERM'));
|
|
878
|
+
if (left.length === 0)
|
|
879
|
+
return;
|
|
880
|
+
const deadline = this.now() + END_KILL_GRACE_MS;
|
|
881
|
+
while (this.now() < deadline && left.some((pid) => signal(pid, 0)))
|
|
882
|
+
await this.sleep(100);
|
|
883
|
+
for (const pid of left)
|
|
884
|
+
if (signal(pid, 0))
|
|
885
|
+
signal(pid, 'SIGKILL');
|
|
886
|
+
}
|
|
887
|
+
/** When each of `pids` started (`paneStarts`); empty when `ps` couldn't say. */
|
|
888
|
+
async startsOf(pids) {
|
|
889
|
+
if (pids.length === 0)
|
|
890
|
+
return new Map();
|
|
891
|
+
try {
|
|
892
|
+
return await (this.options.paneStarts ?? paneStarts)(pids);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return new Map();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Of panes an earlier attempt saved, those whose process group is still this terminal's. A group
|
|
900
|
+
* outlives its leader, and a pid is never handed out while a group of that id exists: so one whose
|
|
901
|
+
* 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.
|
|
904
|
+
*/
|
|
905
|
+
async stillOurs(saved) {
|
|
906
|
+
if (saved.length === 0)
|
|
907
|
+
return [];
|
|
908
|
+
let starts;
|
|
909
|
+
try {
|
|
910
|
+
starts = await (this.options.paneStarts ?? paneStarts)(saved.map((entry) => entry.pid));
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return [];
|
|
914
|
+
}
|
|
915
|
+
return saved
|
|
916
|
+
.filter((entry) => {
|
|
917
|
+
const now = starts.get(entry.pid);
|
|
918
|
+
if (now === undefined)
|
|
919
|
+
return true;
|
|
920
|
+
return entry.startedAtMs !== undefined && Math.abs(now - entry.startedAtMs) <= PANE_START_SLACK_MS;
|
|
921
|
+
})
|
|
922
|
+
.map((entry) => entry.pid);
|
|
923
|
+
}
|
|
658
924
|
async restart(request, outcome = {}, hooks = {}) {
|
|
659
925
|
if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId))
|
|
660
926
|
return false;
|
|
@@ -1155,6 +1421,8 @@ export class CodeTerminals {
|
|
|
1155
1421
|
async attach(request) {
|
|
1156
1422
|
if (!TERMINAL_WINDOWS.has(request.window))
|
|
1157
1423
|
return { ok: false, reason: 'invalid_window' };
|
|
1424
|
+
if (this.ended(request.terminalId))
|
|
1425
|
+
return { ok: false, reason: 'unknown_terminal' };
|
|
1158
1426
|
const record = await this.known(request.terminalId);
|
|
1159
1427
|
if (!record)
|
|
1160
1428
|
return { ok: false, reason: 'unknown_terminal' };
|
|
@@ -1165,10 +1433,17 @@ export class CodeTerminals {
|
|
|
1165
1433
|
if (this.openPtys >= MAX_OPEN_PTYS)
|
|
1166
1434
|
return { ok: false, reason: 'busy' };
|
|
1167
1435
|
const size = { cols: clampDim(request.cols, DEFAULT_COLS), rows: clampDim(request.rows, DEFAULT_ROWS) };
|
|
1168
|
-
if (request.window === 'shell' &&
|
|
1436
|
+
if (request.window === 'shell' &&
|
|
1437
|
+
// Made under the terminal's lifecycle: never once an end has begun, and an end waits for it (and so
|
|
1438
|
+
// finds it to close).
|
|
1439
|
+
!(await this.serializeOn(this.lifecycle, record.terminalId, async () => this.ended(record.terminalId) ? false : this.ensureShellWindow(record, size)))) {
|
|
1169
1440
|
return { ok: false, reason: 'unknown_terminal' };
|
|
1170
1441
|
}
|
|
1171
1442
|
const name = windowName(record.terminalId, request.window);
|
|
1443
|
+
// An end began while this attach waited (`ending` is set before its first await): nothing joins it.
|
|
1444
|
+
// From here to the viewer's registration nothing waits, so a later end finds this viewer to close.
|
|
1445
|
+
if (this.ended(record.terminalId))
|
|
1446
|
+
return { ok: false, reason: 'unknown_terminal' };
|
|
1172
1447
|
const view = viewSessionName();
|
|
1173
1448
|
let pty;
|
|
1174
1449
|
try {
|
|
@@ -1208,11 +1483,20 @@ export class CodeTerminals {
|
|
|
1208
1483
|
while (earlyBytes > MAX_EARLY_OUTPUT_BYTES && early.length > 1)
|
|
1209
1484
|
earlyBytes -= early.shift().length;
|
|
1210
1485
|
});
|
|
1486
|
+
// Closes this viewer and tells it the terminal is gone (an end): registered below.
|
|
1487
|
+
const ended = () => {
|
|
1488
|
+
close();
|
|
1489
|
+
notifyExit();
|
|
1490
|
+
};
|
|
1211
1491
|
const close = () => {
|
|
1212
1492
|
if (closed)
|
|
1213
1493
|
return;
|
|
1214
1494
|
closed = true;
|
|
1215
1495
|
this.openPtys -= 1;
|
|
1496
|
+
const open = this.viewers.get(record.terminalId);
|
|
1497
|
+
open?.delete(ended);
|
|
1498
|
+
if (open?.size === 0)
|
|
1499
|
+
this.viewers.delete(record.terminalId);
|
|
1216
1500
|
sizer.leave();
|
|
1217
1501
|
try {
|
|
1218
1502
|
pty.kill();
|
|
@@ -1223,11 +1507,21 @@ export class CodeTerminals {
|
|
|
1223
1507
|
// destroy-unattached normally reaps it; this covers a pty killed before tmux attached.
|
|
1224
1508
|
void this.tmux(killSessionArgs(view));
|
|
1225
1509
|
};
|
|
1226
|
-
|
|
1227
|
-
|
|
1510
|
+
let exitNotified = false;
|
|
1511
|
+
const notifyExit = () => {
|
|
1512
|
+
if (exitNotified)
|
|
1513
|
+
return;
|
|
1514
|
+
exitNotified = true;
|
|
1228
1515
|
for (const listener of exitListeners)
|
|
1229
1516
|
listener();
|
|
1517
|
+
};
|
|
1518
|
+
pty.onExit(() => {
|
|
1519
|
+
close();
|
|
1520
|
+
notifyExit();
|
|
1230
1521
|
});
|
|
1522
|
+
const open = this.viewers.get(record.terminalId) ?? new Set();
|
|
1523
|
+
open.add(ended);
|
|
1524
|
+
this.viewers.set(record.terminalId, open);
|
|
1231
1525
|
const isAgent = request.window === 'agent';
|
|
1232
1526
|
let tookOver = false;
|
|
1233
1527
|
/** A clear is queued and its result not yet known. */
|
package/dist/codehost/tmux.d.ts
CHANGED
|
@@ -125,6 +125,8 @@ export declare const DAEMON_ONLY_ENV: readonly ["ELECTRON_RUN_AS_NODE", "PROHOST
|
|
|
125
125
|
/** {@link utf8Env}, without {@link DAEMON_ONLY_ENV}: the env every tmux this host runs gets. */
|
|
126
126
|
export declare function paneEnv<T extends Record<string, string | undefined>>(env: T): T;
|
|
127
127
|
export declare function killSessionArgs(viewName: string): string[];
|
|
128
|
+
/** argv (after {@link baseArgs}) that closes one of a thread's windows, killing what its pane runs (tmux hangs it up). */
|
|
129
|
+
export declare function killWindowArgs(name: string): string[];
|
|
128
130
|
/**
|
|
129
131
|
* Text relayed from a composer, as tmux commands: C0 control characters other
|
|
130
132
|
* than newline and tab are dropped — a reply is text, never a key sequence —
|
package/dist/codehost/tmux.js
CHANGED
|
@@ -243,6 +243,10 @@ export function paneEnv(env) {
|
|
|
243
243
|
export function killSessionArgs(viewName) {
|
|
244
244
|
return ['kill-session', '-t', `=${viewName}`];
|
|
245
245
|
}
|
|
246
|
+
/** argv (after {@link baseArgs}) that closes one of a thread's windows, killing what its pane runs (tmux hangs it up). */
|
|
247
|
+
export function killWindowArgs(name) {
|
|
248
|
+
return ['kill-window', '-t', windowTarget(name)];
|
|
249
|
+
}
|
|
246
250
|
/**
|
|
247
251
|
* Text relayed from a composer, as tmux commands: C0 control characters other
|
|
248
252
|
* than newline and tab are dropped — a reply is text, never a key sequence —
|
package/dist/host/command.js
CHANGED
|
@@ -520,6 +520,21 @@ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
|
|
|
520
520
|
return true;
|
|
521
521
|
}
|
|
522
522
|
}
|
|
523
|
+
/** How long a give-back waits for an uninstalled service to finish stopping. */
|
|
524
|
+
const GIVE_BACK_WAIT_MS = 30_000;
|
|
525
|
+
/** Whether `spec`'s service is gone, waiting up to {@link GIVE_BACK_WAIT_MS} for it to stop. */
|
|
526
|
+
function waitUntilGone(spec, serviceIo) {
|
|
527
|
+
const now = serviceIo.now ?? Date.now;
|
|
528
|
+
const sleep = serviceIo.sleep ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
|
|
529
|
+
const deadline = now() + GIVE_BACK_WAIT_MS;
|
|
530
|
+
for (;;) {
|
|
531
|
+
if (!serviceStillLoaded(spec, serviceIo))
|
|
532
|
+
return true;
|
|
533
|
+
if (now() >= deadline)
|
|
534
|
+
return false;
|
|
535
|
+
sleep(500);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
523
538
|
function processAlive(pid) {
|
|
524
539
|
try {
|
|
525
540
|
process.kill(pid, 0);
|
|
@@ -766,7 +781,18 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
766
781
|
for (const o of shown)
|
|
767
782
|
(o.ok ? out : err)(o.message);
|
|
768
783
|
let ok = shown.every((o) => o.ok);
|
|
769
|
-
|
|
784
|
+
// The key goes back at once: the service's definition is gone, so a later run could never
|
|
785
|
+
// find it again, and a key left only in its home (an interrupted wait, say) would be stranded.
|
|
786
|
+
// Uninstall ignores a stop that failed (`launchctl bootout`, `systemctl disable --now`), so the
|
|
787
|
+
// service may still be draining its runs on the old copy: that is waited for (bounded) only
|
|
788
|
+
// afterwards, to say so.
|
|
789
|
+
const handedSpec = note && handedTo ? { ...spec, label: note.handed_to.label, home: handedTo.home } : undefined;
|
|
790
|
+
// Its definition removed (its task deleted), whatever else failed after: on Windows a copy of
|
|
791
|
+
// the key that couldn't be deleted makes uninstall report not ok, yet the task is gone, and
|
|
792
|
+
// no later run could find the key again. Those failures still fail this command (`ok`).
|
|
793
|
+
const definitionGone = (o) => o.ok || o.message.includes('✓ Removed');
|
|
794
|
+
const handedOutcome = note ? outcomes.find((o) => o.label === note.handed_to.label && definitionGone(o)) : undefined;
|
|
795
|
+
if (note && fromHome && handedOutcome) {
|
|
770
796
|
const key = handedKeyLive;
|
|
771
797
|
if (key && key.computer_id === note.computer_id && handedTo) {
|
|
772
798
|
const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
|
|
@@ -777,17 +803,28 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
777
803
|
else {
|
|
778
804
|
// On Windows the (administrators') service home keeps its copy: a later dedicated
|
|
779
805
|
// install finds that home and writes nothing into it, so it must still hold the key.
|
|
780
|
-
|
|
781
|
-
|
|
806
|
+
const cleared = platform === 'win32' || clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
|
|
807
|
+
if (!cleared) {
|
|
808
|
+
// Back here, and still there: the same key in two homes. Not done until that copy goes.
|
|
809
|
+
ok = false;
|
|
810
|
+
err(` ✗ The key is back in ${fromHome} but also still in ${handedTo.home}: delete that copy before using either.`);
|
|
811
|
+
}
|
|
782
812
|
out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
|
|
783
813
|
if (platform === 'win32')
|
|
784
814
|
out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
|
|
785
815
|
}
|
|
786
816
|
}
|
|
787
817
|
else {
|
|
788
|
-
clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err)
|
|
818
|
+
if (!clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err))
|
|
819
|
+
ok = false;
|
|
789
820
|
out(` ${fromHome} no longer points at ${note.handed_to.label}. Its key was not found: add the computer again with prohost host pair, or remove it on its page in ProhostAI.`);
|
|
790
821
|
}
|
|
822
|
+
// Only now (the key is where it belongs): whether the service really stopped, bounded.
|
|
823
|
+
if (handedSpec && !waitUntilGone(handedSpec, io.serviceIo ?? {})) {
|
|
824
|
+
ok = false;
|
|
825
|
+
err(` ! ${note.handed_to.label} was still stopping after ${GIVE_BACK_WAIT_MS / 1000}s: wait until it has exited ` +
|
|
826
|
+
`(${platform === 'darwin' ? `sudo launchctl print system/${note.handed_to.label}` : platform === 'win32' ? 'Task Manager' : `systemctl status ${systemdUnitOf(handedSpec)}`}) before running this computer from ${fromHome}.`);
|
|
827
|
+
}
|
|
791
828
|
}
|
|
792
829
|
return ok ? 0 : 1;
|
|
793
830
|
}
|
|
@@ -815,8 +852,17 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
815
852
|
(env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
|
|
816
853
|
// The key this install takes over, read before it runs: only a copy it really made is retired.
|
|
817
854
|
const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
|
|
818
|
-
//
|
|
819
|
-
const
|
|
855
|
+
// The key the service home already holds, read before this install (then it copies none in).
|
|
856
|
+
const serviceBefore = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) : null;
|
|
857
|
+
// Another computer's: installing would restart that computer under this label and leave this
|
|
858
|
+
// one's key where it is, reported as a takeover. Refused before anything is installed.
|
|
859
|
+
if (handedFrom && serviceBefore && serviceBefore.computer_id !== handedFrom.computer_id) {
|
|
860
|
+
err(`✗ ${home} already holds the key of another computer ("${serviceBefore.name}", ${serviceBefore.computer_id}), not ` +
|
|
861
|
+
`${adoptFrom}'s ("${handedFrom.name}"): nothing was installed. Remove that computer first ` +
|
|
862
|
+
`(sudo prohost host unpair --label ${spec.label}), then install again.`);
|
|
863
|
+
return 1;
|
|
864
|
+
}
|
|
865
|
+
const serviceHadKey = serviceBefore !== null;
|
|
820
866
|
const outcome = installService(spec, io.serviceIo, {
|
|
821
867
|
...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
|
|
822
868
|
...(dedicated ? { adoptFrom } : {}),
|
|
@@ -828,6 +874,38 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
828
874
|
? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
|
|
829
875
|
: ` This home (${home}) has no computer yet: run prohost host pair.`);
|
|
830
876
|
}
|
|
877
|
+
// Undo this install (it copied a key in): the service uninstalled, every copy of the key it made
|
|
878
|
+
// removed, and the original home keeps its key. Returns the exit code.
|
|
879
|
+
const undoInstall = (options = { removeKeys: true }) => {
|
|
880
|
+
const undone = (() => {
|
|
881
|
+
try {
|
|
882
|
+
return uninstallService(spec, io.serviceIo, { keepKeyCopy: !options.removeKeys });
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
889
|
+
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
890
|
+
// (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
|
|
891
|
+
// although the task is gone — that copy is named by removeCopiedKeys below.)
|
|
892
|
+
const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
893
|
+
if (!options.removeKeys) {
|
|
894
|
+
// Which file this install copied can't be told from one it didn't: no credential is deleted.
|
|
895
|
+
err(serviceGone
|
|
896
|
+
? ` ✗ ${spec.label} is uninstalled. Check ${path.join(home, 'host', 'computer.json')} before installing again; ${adoptFrom} keeps its key.`
|
|
897
|
+
: ` ✗ Could not uninstall ${spec.label}: remove it (sudo prohost host uninstall --label ${spec.label}), then check ${path.join(home, 'host', 'computer.json')}.`);
|
|
898
|
+
return 1;
|
|
899
|
+
}
|
|
900
|
+
const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
|
|
901
|
+
err(serviceGone && leftover.length === 0
|
|
902
|
+
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
903
|
+
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
904
|
+
: serviceGone
|
|
905
|
+
? ` ✗ Partly undone: ${spec.label} is uninstalled, but its key is still in ${leftover.join(' and ')}: delete it yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`
|
|
906
|
+
: ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
907
|
+
return 1;
|
|
908
|
+
};
|
|
831
909
|
if (outcome.ok && dedicated && handedFrom) {
|
|
832
910
|
// One key, one holder: the home it came from keeps a note of where the computer went, never
|
|
833
911
|
// the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
|
|
@@ -868,27 +946,7 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
868
946
|
return 1;
|
|
869
947
|
}
|
|
870
948
|
else if (!serviceHadKey) {
|
|
871
|
-
|
|
872
|
-
try {
|
|
873
|
-
return uninstallService(spec, io.serviceIo);
|
|
874
|
-
}
|
|
875
|
-
catch (e) {
|
|
876
|
-
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
877
|
-
}
|
|
878
|
-
})();
|
|
879
|
-
// Removed and no longer loaded, not just "nothing failed": a Windows task that would not
|
|
880
|
-
// stop removes nothing, and a launchd / systemd service whose stop failed may still run.
|
|
881
|
-
// (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
|
|
882
|
-
// although the task is gone — that copy is named by removeCopiedKeys below.)
|
|
883
|
-
const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
|
|
884
|
-
const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
|
|
885
|
-
err(serviceGone && leftover.length === 0
|
|
886
|
-
? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
|
|
887
|
-
(platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
|
|
888
|
-
: serviceGone
|
|
889
|
-
? ` ✗ Partly undone: ${spec.label} is uninstalled, but its key is still in ${leftover.join(' and ')}: delete it yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`
|
|
890
|
-
: ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
891
|
-
return 1;
|
|
949
|
+
return undoInstall();
|
|
892
950
|
}
|
|
893
951
|
else {
|
|
894
952
|
err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
|
|
@@ -901,6 +959,20 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
|
|
|
901
959
|
out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
|
|
902
960
|
}
|
|
903
961
|
}
|
|
962
|
+
else if (!serviceHadKey) {
|
|
963
|
+
// The copy this install made isn't there afterwards (or can't be read, or is another
|
|
964
|
+
// computer's): no takeover happened, and the service must not run on whatever it holds.
|
|
965
|
+
// Uninstalled; its files are left for a person to judge.
|
|
966
|
+
err(` ✗ ${spec.label} was installed, but ${home} does not hold ${adoptFrom}'s key afterwards ` +
|
|
967
|
+
(serviceHas ? `(it holds ${serviceHas.computer_id}).` : "(it can't be read)."));
|
|
968
|
+
return undoInstall({ removeKeys: false });
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
// It held this computer's key before the install and can't be read now: never reported done.
|
|
972
|
+
err(` ✗ ${home} held this computer's key before this install, but it can't be read now: check ` +
|
|
973
|
+
`${path.join(home, 'host', 'computer.json')}, and never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
904
976
|
}
|
|
905
977
|
if (outcome.ok && dedicated) {
|
|
906
978
|
out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
|
package/dist/host/install.d.ts
CHANGED
|
@@ -219,6 +219,12 @@ export declare function aclPrincipals(icaclsOutput: string, dir: string): string
|
|
|
219
219
|
/** The files a dedicated service publishes in its shared folder, and the pause switch. */
|
|
220
220
|
export declare const SHARED_FILES: readonly ["state.json", "activity.json", "events.json"];
|
|
221
221
|
export declare function installService(spec: ServiceSpec, io?: InstallIo, setup?: DedicatedSetup): InstallOutcome;
|
|
222
|
-
|
|
222
|
+
/**
|
|
223
|
+
* `keepKeyCopy`: on Windows, leave the administrators' copy of the key (an install undoing itself
|
|
224
|
+
* that can't tell a copy it made from one it found keeps it for a person to judge).
|
|
225
|
+
*/
|
|
226
|
+
export declare function uninstallService(spec: ServiceSpec, io?: InstallIo, options?: {
|
|
227
|
+
keepKeyCopy?: boolean;
|
|
228
|
+
}): InstallOutcome;
|
|
223
229
|
/** Where a dedicated service user's `PROHOST_HOME` is by default. */
|
|
224
230
|
export declare function serviceUserHome(platform: NodeJS.Platform, user: string): string;
|
package/dist/host/install.js
CHANGED
|
@@ -1002,7 +1002,13 @@ export function installService(spec, io = {}, setup = {}) {
|
|
|
1002
1002
|
return { ok: false, file, message: `Created ${task}, but ${failed('schtasks /Run', start)}` };
|
|
1003
1003
|
return { ok: true, file, message: `✓ Installed the ${task} task\n logs: ${logs}` };
|
|
1004
1004
|
}
|
|
1005
|
-
|
|
1005
|
+
/** `schtasks /Query /HResult`'s exit for a task that doesn't exist: HRESULT 0x80070002, signed or unsigned. */
|
|
1006
|
+
const TASK_NOT_FOUND = new Set([0x80070002, 0x80070002 - 0x100000000]);
|
|
1007
|
+
/**
|
|
1008
|
+
* `keepKeyCopy`: on Windows, leave the administrators' copy of the key (an install undoing itself
|
|
1009
|
+
* that can't tell a copy it made from one it found keeps it for a person to judge).
|
|
1010
|
+
*/
|
|
1011
|
+
export function uninstallService(spec, io = {}, options = {}) {
|
|
1006
1012
|
const run = io.run ?? defaultServiceRunner;
|
|
1007
1013
|
const remove = io.removeFile ?? ((file) => rmSync(file, { force: true }));
|
|
1008
1014
|
const exists = io.exists ?? existsSync;
|
|
@@ -1048,13 +1054,30 @@ export function uninstallService(spec, io = {}) {
|
|
|
1048
1054
|
const out = run('schtasks', ['/Delete', '/TN', task, '/F']);
|
|
1049
1055
|
if (out.status === 0)
|
|
1050
1056
|
removed.push(task);
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1057
|
+
// A delete that failed is a failure, never "nothing installed", unless the task is confirmed
|
|
1058
|
+
// absent (Task Scheduler says it can't find it): the task can start again on its key. An error
|
|
1059
|
+
// that can't tell (the scheduler not answering) counts as still there. Its files stay, so a
|
|
1060
|
+
// later uninstall still finds it.
|
|
1061
|
+
// `/HResult` makes the exit code the HRESULT, whatever the system's language: a task that isn't
|
|
1062
|
+
// there is ERROR_FILE_NOT_FOUND (0x80070002), seen signed or unsigned. The English text is kept
|
|
1063
|
+
// for a schtasks without the switch.
|
|
1064
|
+
const query = out.status === 0 ? undefined : run('schtasks', ['/Query', '/TN', task, '/HResult']);
|
|
1065
|
+
const confirmedAbsent = query !== undefined &&
|
|
1066
|
+
query.status !== 0 &&
|
|
1067
|
+
(TASK_NOT_FOUND.has(query.status ?? 0) || /cannot find|does not exist/i.test(`${query.stderr} ${query.stdout}`));
|
|
1068
|
+
const stillThere = out.status !== 0 && !confirmedAbsent;
|
|
1069
|
+
if (stillThere) {
|
|
1070
|
+
notes.push(`could not delete the task ${task} (${(out.stderr || out.stdout || `exit ${out.status}`).trim()}): it is still installed`);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
for (const file of [serviceFilePath(spec), windowsWrapperPath(spec)]) {
|
|
1074
|
+
if (exists(file))
|
|
1075
|
+
remove(file);
|
|
1076
|
+
}
|
|
1054
1077
|
}
|
|
1055
1078
|
// The administrators' copy of its key goes with the service: a reinstall never finds an old one.
|
|
1056
1079
|
const copy = windowsServiceCredentialPath(spec);
|
|
1057
|
-
if (out.status === 0 && exists(copy)) {
|
|
1080
|
+
if (out.status === 0 && !options.keepKeyCopy && exists(copy)) {
|
|
1058
1081
|
try {
|
|
1059
1082
|
remove(copy);
|
|
1060
1083
|
}
|
package/dist/host/service.js
CHANGED
|
@@ -38,7 +38,7 @@ import { HeartbeatLoop } from '../agent/heartbeat.js';
|
|
|
38
38
|
import { executeHostedRun } from '../agent/run.js';
|
|
39
39
|
import { SessionStore, sessionKeyFor } from '../agent/sessionstore.js';
|
|
40
40
|
import { verifySignature } from '../agent/signature.js';
|
|
41
|
-
import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
|
|
41
|
+
import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_END_SESSION_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_END_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
|
|
42
42
|
import { HostStatsSampler } from '../codehost/host_stats.js';
|
|
43
43
|
import { TERMINAL_SESSIONS_ENV, loadSettings as loadCodeHostSettings, recordServiceSwitch } from '../codehost/settings.js';
|
|
44
44
|
import { isWebhookEvent } from '../forward.js';
|
|
@@ -258,6 +258,9 @@ async function serveHost(options) {
|
|
|
258
258
|
if (codeHost?.authorized() && config.terminal_sessions_enabled) {
|
|
259
259
|
caps.push(CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability());
|
|
260
260
|
caps.push(...(codeHost.agentCapabilities?.() ?? []));
|
|
261
|
+
// Ending a thread's terminal (`code_thread.end_requested`): whatever the remote switch says.
|
|
262
|
+
if (codeHost.handleEnd)
|
|
263
|
+
caps.push(CODE_THREAD_END_SESSION_CAPABILITY);
|
|
261
264
|
if (codeHost.remoteTerminalAllowed()) {
|
|
262
265
|
caps.push(REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY);
|
|
263
266
|
// Relaunching a running thread's agent without prompts (`code_thread.restart_requested`): the same rule.
|
|
@@ -998,7 +1001,8 @@ async function serveHost(options) {
|
|
|
998
1001
|
if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
|
|
999
1002
|
frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
|
|
1000
1003
|
frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED ||
|
|
1001
|
-
frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
|
|
1004
|
+
frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED ||
|
|
1005
|
+
frame.event === EVENT_CODE_THREAD_END_REQUESTED) {
|
|
1002
1006
|
if (!codeHost || !config.terminal_sessions_enabled) {
|
|
1003
1007
|
ack(ACK_IGNORED);
|
|
1004
1008
|
return;
|
|
@@ -1032,7 +1036,9 @@ async function serveHost(options) {
|
|
|
1032
1036
|
? codeHost.handleInput(data, frame.delivery_id)
|
|
1033
1037
|
: frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
|
|
1034
1038
|
? (codeHost.handleRestart?.(data, frame.delivery_id) ?? Promise.resolve(false))
|
|
1035
|
-
:
|
|
1039
|
+
: frame.event === EVENT_CODE_THREAD_END_REQUESTED
|
|
1040
|
+
? (codeHost.handleEnd?.(data) ?? Promise.resolve(false))
|
|
1041
|
+
: codeHost.handleApproval(data, frame.delivery_id);
|
|
1036
1042
|
background.push(handled
|
|
1037
1043
|
.then((ok) => {
|
|
1038
1044
|
ack(starting || ok ? ACK_ACCEPTED : ACK_IGNORED);
|
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.10";
|
|
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.10";
|
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.10';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|