@prohost/cli 0.16.8 → 0.16.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.11
10
+
11
+ - **An end that can't check what is left of a terminal is retried, never said done.** When a retry
12
+ of `code_thread.end_requested` (after a kill whose tmux answer was lost) couldn't ask `ps` which
13
+ saved panes still belong to the terminal, it reported the end done with nothing reaped, so a
14
+ process group that outlived its window could keep running. Now what was just closed is reaped,
15
+ the saved panes stay in the unfinished `.ended` mark, and the end is asked for again until `ps`
16
+ answers.
17
+
18
+ ## 0.16.10
19
+
20
+ - **A Code thread's terminal can be ended from ProhostAI.** `code_thread.end_requested` closes the
21
+ thread's viewers, its agent and shell windows (and a shell left without its agent window), and
22
+ whatever of their process groups is left (SIGTERM, then SIGKILL after 2 s). Its windows were the
23
+ only record of it, so it is never adopted again and drops out of `running`, freeing its slot; its
24
+ folder is left as it is. Idempotent (a terminal already gone is ended), taken whatever the remote
25
+ switch says (ending never types), refused as `end_expired` past 10 minutes, retried when tmux
26
+ doesn't answer. Declared as `code_thread_end_session`; an older CLI never declares it and acks
27
+ the event ignored.
28
+ - An ended terminal leaves a mark (`state/<terminal>.ended`) holding its pane processes, each with
29
+ when it started and the boot it ran in: a start delivered again after a lost ack never relaunches
30
+ it, a retry after a kill whose answer was lost still reaps what it ran (never a pid since handed to
31
+ another process, or from before a restart), a late redelivery of a finished end is answered ended,
32
+ and a viewer's shell is never made once an end has begun.
33
+
34
+ ## 0.16.9
35
+
36
+ - **Windows: a scheduled task counts as gone only when Task Scheduler says so.** An `uninstall`
37
+ whose `schtasks /Delete` failed, and whose `/Query` also failed for another reason (the scheduler
38
+ not answering), took the task as absent and gave the key back. Only a "cannot find" answer counts
39
+ as absent now; anything else is a failure that keeps the task's files and gives nothing back.
40
+ - **A dedicated install undoing itself keeps the administrators' key copy on Windows** when it
41
+ can't tell that copy is its own (`uninstallService`'s new `keepKeyCopy`), as it already kept the
42
+ service home's file.
43
+
9
44
  ## 0.16.8
10
45
 
11
46
  - **A dedicated install never takes over a service home that holds another computer.** When the
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
- : codeHost.handleApproval(data, frame.delivery_id);
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;
@@ -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
  /**
@@ -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 is left alone.
548
+ * `undefined` when `ps` couldn't say: nothing is judged, and the caller keeps them for a retry.
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
- const previous = this.typing.get(terminalId) ?? Promise.resolve();
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
- this.typing.set(terminalId, tail);
342
+ queues.set(terminalId, tail);
291
343
  void tail.then(() => {
292
- if (this.typing.get(terminalId) === tail)
293
- this.typing.delete(terminalId);
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,225 @@ 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
+ const ours = await this.stillOurs(saved);
860
+ if (ours === undefined) {
861
+ // `ps` couldn't say which saved panes are still this terminal's: what was just closed is reaped,
862
+ // the rest stays saved in the unfinished mark, and the end is asked for again rather than said done.
863
+ await this.reapGroups(fresh.map((entry) => entry.pid));
864
+ try {
865
+ this.markEnded(request, saved, false);
866
+ }
867
+ catch {
868
+ /* the earlier mark still holds them */
869
+ }
870
+ this.log(` ! code thread ${short}: couldn't check what is left of its terminal; retrying`);
871
+ outcome.retry = true;
872
+ return false;
873
+ }
874
+ await this.reapGroups(fresh.map((entry) => entry.pid), ours);
875
+ try {
876
+ // Finished and reaped: nothing left for a retry to signal, and a late redelivery is answered ended.
877
+ this.markEnded(request, [], true);
878
+ }
879
+ catch {
880
+ /* the mark stays with its pids; only a recent one is ever reaped again */
881
+ }
882
+ this.log(` ■ code thread ${short}: terminal ${names.length > 0 ? 'ended' : 'already ended'}`);
883
+ return true;
884
+ }
885
+ /**
886
+ * What a closed window ran and didn't exit on its hangup (a process ignoring SIGHUP, a child in its
887
+ * group): SIGTERM, then SIGKILL past {@link END_KILL_GRACE_MS}. Only each pane's own process group:
888
+ * never a process that left it on purpose (`setsid`, a daemon it started).
889
+ */
890
+ async reapGroups(fresh, saved = []) {
891
+ const signal = this.options.signalGroup ?? signalProcessGroup;
892
+ const left = [...new Set([...fresh, ...saved])].filter((pid) => signal(pid, 'SIGTERM'));
893
+ if (left.length === 0)
894
+ return;
895
+ const deadline = this.now() + END_KILL_GRACE_MS;
896
+ while (this.now() < deadline && left.some((pid) => signal(pid, 0)))
897
+ await this.sleep(100);
898
+ for (const pid of left)
899
+ if (signal(pid, 0))
900
+ signal(pid, 'SIGKILL');
901
+ }
902
+ /** When each of `pids` started (`paneStarts`); empty when `ps` couldn't say. */
903
+ async startsOf(pids) {
904
+ if (pids.length === 0)
905
+ return new Map();
906
+ try {
907
+ return await (this.options.paneStarts ?? paneStarts)(pids);
908
+ }
909
+ catch {
910
+ return new Map();
911
+ }
912
+ }
913
+ /**
914
+ * Of panes an earlier attempt saved, those whose process group is still this terminal's. A group
915
+ * outlives its leader, and a pid is never handed out while a group of that id exists: so one whose
916
+ * leader pid runs no process now is ours if anything is left in it. One whose pid runs a process is
917
+ * ours only if that process started when the saved one did; one with no saved start is left alone.
918
+ * `undefined` when `ps` couldn't say: nothing is judged, and the caller keeps them for a retry.
919
+ */
920
+ async stillOurs(saved) {
921
+ if (saved.length === 0)
922
+ return [];
923
+ let starts;
924
+ try {
925
+ starts = await (this.options.paneStarts ?? paneStarts)(saved.map((entry) => entry.pid));
926
+ }
927
+ catch {
928
+ return undefined;
929
+ }
930
+ return saved
931
+ .filter((entry) => {
932
+ const now = starts.get(entry.pid);
933
+ if (now === undefined)
934
+ return true;
935
+ return entry.startedAtMs !== undefined && Math.abs(now - entry.startedAtMs) <= PANE_START_SLACK_MS;
936
+ })
937
+ .map((entry) => entry.pid);
938
+ }
658
939
  async restart(request, outcome = {}, hooks = {}) {
659
940
  if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId))
660
941
  return false;
@@ -1155,6 +1436,8 @@ export class CodeTerminals {
1155
1436
  async attach(request) {
1156
1437
  if (!TERMINAL_WINDOWS.has(request.window))
1157
1438
  return { ok: false, reason: 'invalid_window' };
1439
+ if (this.ended(request.terminalId))
1440
+ return { ok: false, reason: 'unknown_terminal' };
1158
1441
  const record = await this.known(request.terminalId);
1159
1442
  if (!record)
1160
1443
  return { ok: false, reason: 'unknown_terminal' };
@@ -1165,10 +1448,17 @@ export class CodeTerminals {
1165
1448
  if (this.openPtys >= MAX_OPEN_PTYS)
1166
1449
  return { ok: false, reason: 'busy' };
1167
1450
  const size = { cols: clampDim(request.cols, DEFAULT_COLS), rows: clampDim(request.rows, DEFAULT_ROWS) };
1168
- if (request.window === 'shell' && !(await this.ensureShellWindow(record, size))) {
1451
+ if (request.window === 'shell' &&
1452
+ // Made under the terminal's lifecycle: never once an end has begun, and an end waits for it (and so
1453
+ // finds it to close).
1454
+ !(await this.serializeOn(this.lifecycle, record.terminalId, async () => this.ended(record.terminalId) ? false : this.ensureShellWindow(record, size)))) {
1169
1455
  return { ok: false, reason: 'unknown_terminal' };
1170
1456
  }
1171
1457
  const name = windowName(record.terminalId, request.window);
1458
+ // An end began while this attach waited (`ending` is set before its first await): nothing joins it.
1459
+ // From here to the viewer's registration nothing waits, so a later end finds this viewer to close.
1460
+ if (this.ended(record.terminalId))
1461
+ return { ok: false, reason: 'unknown_terminal' };
1172
1462
  const view = viewSessionName();
1173
1463
  let pty;
1174
1464
  try {
@@ -1208,11 +1498,20 @@ export class CodeTerminals {
1208
1498
  while (earlyBytes > MAX_EARLY_OUTPUT_BYTES && early.length > 1)
1209
1499
  earlyBytes -= early.shift().length;
1210
1500
  });
1501
+ // Closes this viewer and tells it the terminal is gone (an end): registered below.
1502
+ const ended = () => {
1503
+ close();
1504
+ notifyExit();
1505
+ };
1211
1506
  const close = () => {
1212
1507
  if (closed)
1213
1508
  return;
1214
1509
  closed = true;
1215
1510
  this.openPtys -= 1;
1511
+ const open = this.viewers.get(record.terminalId);
1512
+ open?.delete(ended);
1513
+ if (open?.size === 0)
1514
+ this.viewers.delete(record.terminalId);
1216
1515
  sizer.leave();
1217
1516
  try {
1218
1517
  pty.kill();
@@ -1223,11 +1522,21 @@ export class CodeTerminals {
1223
1522
  // destroy-unattached normally reaps it; this covers a pty killed before tmux attached.
1224
1523
  void this.tmux(killSessionArgs(view));
1225
1524
  };
1226
- pty.onExit(() => {
1227
- close();
1525
+ let exitNotified = false;
1526
+ const notifyExit = () => {
1527
+ if (exitNotified)
1528
+ return;
1529
+ exitNotified = true;
1228
1530
  for (const listener of exitListeners)
1229
1531
  listener();
1532
+ };
1533
+ pty.onExit(() => {
1534
+ close();
1535
+ notifyExit();
1230
1536
  });
1537
+ const open = this.viewers.get(record.terminalId) ?? new Set();
1538
+ open.add(ended);
1539
+ this.viewers.set(record.terminalId, open);
1231
1540
  const isAgent = request.window === 'agent';
1232
1541
  let tookOver = false;
1233
1542
  /** A clear is queued and its result not yet known. */
@@ -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 —
@@ -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 —
@@ -879,7 +879,7 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
879
879
  const undoInstall = (options = { removeKeys: true }) => {
880
880
  const undone = (() => {
881
881
  try {
882
- return uninstallService(spec, io.serviceIo);
882
+ return uninstallService(spec, io.serviceIo, { keepKeyCopy: !options.removeKeys });
883
883
  }
884
884
  catch (e) {
885
885
  return { ok: false, message: e instanceof Error ? e.message : String(e) };
@@ -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
- export declare function uninstallService(spec: ServiceSpec, io?: InstallIo): InstallOutcome;
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;
@@ -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
- export function uninstallService(spec, io = {}) {
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,9 +1054,18 @@ 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
- // A delete that failed while the task is still there is a failure, never "nothing installed":
1052
- // the task can start again on its key. Its files stay, so a later uninstall still finds it.
1053
- const stillThere = out.status !== 0 && run('schtasks', ['/Query', '/TN', task]).status === 0;
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;
1054
1069
  if (stillThere) {
1055
1070
  notes.push(`could not delete the task ${task} (${(out.stderr || out.stdout || `exit ${out.status}`).trim()}): it is still installed`);
1056
1071
  }
@@ -1062,7 +1077,7 @@ export function uninstallService(spec, io = {}) {
1062
1077
  }
1063
1078
  // The administrators' copy of its key goes with the service: a reinstall never finds an old one.
1064
1079
  const copy = windowsServiceCredentialPath(spec);
1065
- if (out.status === 0 && exists(copy)) {
1080
+ if (out.status === 0 && !options.keepKeyCopy && exists(copy)) {
1066
1081
  try {
1067
1082
  remove(copy);
1068
1083
  }
@@ -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
- : codeHost.handleApproval(data, frame.delivery_id);
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.8";
10
+ export declare const CLI_VERSION = "0.16.11";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.8";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.11";
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.8';
10
+ export const CLI_VERSION = '0.16.11';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.8",
3
+ "version": "0.16.11",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",