@prohost/cli 0.16.4 → 0.16.6

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,58 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.6
10
+
11
+ - **A hand-off note that can't be written undoes the dedicated install.** The note is part of the
12
+ takeover: without it the computer's key stayed in both homes, and a plain `host unpair` in the
13
+ original one would remove the computer under the running service. Now the service is
14
+ uninstalled and the key the install copied is removed (on Windows its administrators' copy too),
15
+ and the install fails with what to fix. A service home that already held the key before keeps
16
+ it, and the install still fails.
17
+
18
+ ## 0.16.5
19
+
20
+ - **A dedicated install takes the computer's key over.** `sudo prohost host install --dedicated`
21
+ used to leave a live copy of the key in the home it took the computer from, so an `unpair`
22
+ there removed the computer under the service and a personal `install` ran it twice. Once the
23
+ service home has the computer, that home keeps only a note of where it went (no key, written as
24
+ its owner; `code_host_home` kept for the desktop app). `host status` there still reads paired
25
+ (that computer, offline here) and reports `handed_to`;
26
+ `unpair`, `pair` and a personal `install` refuse and name the service. A note that can't be
27
+ written is reported loudly; a failed install leaves the key where it was.
28
+ - **`sudo prohost host unpair --label <id>`** removes a dedicated service's computer with the
29
+ service's own key, uninstalls that service and clears both homes; nothing changes when the
30
+ server refuses.
31
+ - **`sudo prohost host uninstall --label <id>`** gives the key back to the home the service took it
32
+ from (`prohost host install` runs it there again). `unpair --label` trusts only the service's
33
+ installed definition (one of our labels; its home must be the account's own folder, no link)
34
+ and refuses a note that disagrees; a label taken from the note acts only on the service that
35
+ runs the note's computer. On Windows the note's folders are re-checked (real paths) before,
36
+ during and after the write (best effort: it narrows a folder swapped in between, it does not
37
+ close it). A dedicated label starts with `ai.prohost.host`. Root reads a service home's key as
38
+ the service's account; on Windows a dedicated install keeps an administrators-only copy of the
39
+ key in `ProgramData\Prohost\services\<label>`, the only place an administrator's unpair /
40
+ uninstall reads it (one handle: a plain file, nlink 1), and only while the service still
41
+ publishes that computer; it goes with the service, and a dedicated install handed no computer
42
+ leaves none behind. Uninstalling a dedicated service removes
43
+ its published state. `host run` and `host migrate` respect the note.
44
+ - **Desktop:** a hand-off note makes the app treat this computer as dedicated (and records its
45
+ label), even when the dedicated install ran from a terminal. "Remove this computer" for a
46
+ computer dedicated that way runs the labelled unpair through the administrator prompt, never
47
+ deletes the note, and says so when the computer still runs.
48
+ - **Restart a terminal's agent without permission prompts.** `code_thread.restart_requested`
49
+ relaunches a thread's Claude Code or Codex in its own tmux window and folder (`respawn-pane -k`:
50
+ the window, its viewers and its size stay), with the flags and `--settings` documents a start that
51
+ skips prompts gets. It resumes exactly the session the window's hooks recorded (they now write
52
+ `code-host/state/<terminal>.session`: Claude Code's `session_id`, Codex's `thread-id`, and the
53
+ login) with `claude --resume <id>` / `codex resume <id>`; a Claude session with no conversation yet
54
+ starts fresh; only a worktree this host made falls back to `--continue` / `resume --last`. Refused
55
+ (`409`) with remote control off, a gone window, a shell, an unknown session in a shared folder
56
+ (`session_unknown`), another or no login (`login_changed`, `login_unavailable`), or past 10 minutes
57
+ (`restart_expired`); a redelivery of one done is acked as done. Declared as
58
+ `code_thread_restart_skip_permissions` beside `code_thread_skip_permissions`.
59
+ - Enable disabled macOS services before loading them, and report enable failures instead of claiming the install succeeded.
60
+
9
61
  ## 0.16.4
10
62
 
11
63
  - **`409 conversation_busy` on claim** (ProhostAI serializes a conversation's turns): the run
package/README.md CHANGED
@@ -70,7 +70,8 @@ paired that way.
70
70
  | `host pair --token <redeem_token>` | Redeems a one-time token from the signed-in desktop app instead (`--token -` reads it from stdin; `$PROHOST_REDEEM_TOKEN` also works). The token is never printed. |
71
71
  | `host run [--machine <n>] [--timeout <s>] [--idle-timeout <s>] [--workload coding\|chat]` | The service, in the foreground. |
72
72
  | `host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>] [--claude-token-file <path>]]` / `host uninstall [--label <id>]` | The background service for this OS (below). Safe to re-run: nothing that is already right is touched. |
73
- | `host unpair` | Remove this computer from its workspace (`DELETE /v1/computers/{id}` with its own key), then delete `computer.json`. Exits 0 only when the server removed it, or had already. |
73
+ | `host unpair` | Remove this computer from its workspace (`DELETE /v1/computers/{id}` with its own key), then delete `computer.json`. Exits 0 only when the server removed it, or had already. In a home that handed its computer to a dedicated service it refuses and names the service. |
74
+ | `sudo prohost host unpair --label <id> [--service-user <name>] [--from-home <dir>]` | Remove a dedicated service's computer: as root / an administrator, with the key in the service's own home; then uninstall that service (stopped, its definition removed) and delete the key there and the hand-off note in the home it came from. Nothing changes when the server refuses. The label must be one of ours with an installed definition; the service's home and account come from that definition only (the home must be the account's own folder, no link), and a note that disagrees with it is refused. `--dedicated` without `--label` takes the label from this home's note, and then acts only when that service runs the note's computer. |
74
75
  | `host status [--json] [--label <id>]` | Online, paused, capacity, what is running, AI logins, hardware, recent events. Always exits 0. |
75
76
  | `host logins [--json]` | The AI logins found on this machine, with their plan and (locally only) email. |
76
77
  | `host logins add --runtime claude_code\|codex [--label <l>]` | A new AI login in its own config directory (`~/.prohost-accounts`), signed in right here (`claude auth login` / `codex login`). |
@@ -241,7 +242,17 @@ runs per `PROHOST_HOME` (`host/service.lock`); a second one refuses to start.
241
242
  The systemd unit uses `KillMode=mixed`, so a stop reaches the host first and its
242
243
  runs can finish. Re-installing on macOS waits for the previous service to finish
243
244
  draining (up to its 10-minute stop timeout) before loading the new one. A dedicated install hands the service user's `PROHOST_HOME` to
244
- that user (`chown`); on Windows it checks for an elevated prompt first. **Updates drain:**
245
+ that user (`chown`); on Windows it checks for an elevated prompt first. **One key, one
246
+ holder:** once the service home has the computer, the home it was taken from (`--from-home`,
247
+ or `$SUDO_USER`'s) keeps only a note — `{handed_to: {label, service_user, service_home, at},
248
+ computer_id, name, account, code_host_home?}`, no key — written as that home's owner.
249
+ `host status` there reports `handed_to`; `unpair`, `pair` and a personal `install` refuse and
250
+ name the service. The note is part of the takeover: if it can't be written, the install is
251
+ undone (the service uninstalled, the key it copied removed) and fails, so the key is never left in
252
+ both homes; a failed install leaves the key where it was. `sudo prohost host uninstall --label <id>` gives the key back to
253
+ that home (so `prohost host install` runs it there again); `sudo prohost host unpair --label <id>`
254
+ removes the computer. Both read the service's home from its installed definition (root's file),
255
+ never from the note, which the home's owner can edit. **Updates drain:**
245
256
  when a newer CLI is installed over the running one (or the service is told to
246
257
  stop), it takes no new work, lets the runs going finish, then exits (code 75
247
258
  after an update) for the service manager to start the new one. A second
@@ -297,7 +308,10 @@ start it again. `--dry-run` shows what it would do; `--json` prints the report.
297
308
  `service` says whether a service runs for this home (`host/service.lock`), how
298
309
  many homes it adopted, and — with `--label <id>` of a dedicated service — that
299
310
  it is the dedicated service (`activity_file` is then its shared copy).
300
- `remote_terminal` says whether remote terminal sessions are on.
311
+ `remote_terminal` says whether remote terminal sessions are on. `handed_to`
312
+ (`{label, service_user, service_home}`) is there when this home gave its computer
313
+ to a dedicated service: it then reads `paired: true` with that computer and
314
+ `online: false` here — ask the service with `--label`.
301
315
  - `host logins --json` → `{logins: [...same shape as status...]}`.
302
316
  - `$PROHOST_HOME/host/activity.json` → `{v: 1, busy, running: [...as in status...], updatedAt}`.
303
317
  - `$PROHOST_HOME/host/state.json` is the service's own state (0600); read
@@ -282,6 +282,18 @@ export function installLaunchdDaemon(spec, options = {}) {
282
282
  timeoutMs: options.exitTimeoutMs ?? OLD_JOB_EXIT_TIMEOUT_MS,
283
283
  onWaiting: options.onWaiting ?? ((line) => process.stderr.write(`${line}\n`)),
284
284
  });
285
+ // A disabled label cannot load: enabling must succeed before bootstrap.
286
+ const enable = run('launchctl', ['enable', `${domain}/${spec.label}`]);
287
+ if (enable.status !== 0) {
288
+ return {
289
+ ok: false,
290
+ plistPath,
291
+ message: `Wrote ${plistPath}, but \`launchctl enable ${domain}/${spec.label}\` failed` +
292
+ `${enable.stderr.trim() ? `: ${enable.stderr.trim()}` : ` (exit ${String(enable.status)})`}\n` +
293
+ `Enable it by hand with: launchctl enable ${domain}/${spec.label}\n` +
294
+ `Then rerun installation, or load the written plist with: launchctl bootstrap ${domain} ${plistPath}`,
295
+ };
296
+ }
285
297
  const bootstrap = run('launchctl', ['bootstrap', domain, plistPath]);
286
298
  if (bootstrap.status !== 0) {
287
299
  return {
@@ -293,7 +305,6 @@ export function installLaunchdDaemon(spec, options = {}) {
293
305
  `Load it by hand with: launchctl bootstrap ${domain} ${plistPath}`,
294
306
  };
295
307
  }
296
- run('launchctl', ['enable', `${domain}/${spec.label}`]);
297
308
  return {
298
309
  ok: true,
299
310
  plistPath,
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_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.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';
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';
@@ -1652,7 +1652,12 @@ export async function runAgentHarness(options) {
1652
1652
  // With it, skipping permission prompts (`skipPermissions` on a start): only while remote control
1653
1653
  // is on, as the host applies it only then; the server records a thread as skipping only so.
1654
1654
  ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed()
1655
- ? [REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY]
1655
+ ? [
1656
+ REMOTE_TERMINAL_CAPABILITY,
1657
+ CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY,
1658
+ // And relaunching a running thread's agent so (`code_thread.restart_requested`), by the same rule.
1659
+ ...(codeHost.handleRestart ? [CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY] : []),
1660
+ ]
1656
1661
  : []),
1657
1662
  ],
1658
1663
  // `--machine` names this machine everywhere, exactly as given: whoever set it never sends the
@@ -2075,7 +2080,8 @@ export async function runAgentHarness(options) {
2075
2080
  }
2076
2081
  if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
2077
2082
  frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
2078
- frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED) {
2083
+ frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED ||
2084
+ frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED) {
2079
2085
  if (!codeHost) {
2080
2086
  if (!codeHostIgnoredLogged) {
2081
2087
  codeHostIgnoredLogged = true;
@@ -2115,7 +2121,9 @@ export async function runAgentHarness(options) {
2115
2121
  ? codeHost.handleStart(data)
2116
2122
  : frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED
2117
2123
  ? codeHost.handleInput(data, frame.delivery_id)
2118
- : codeHost.handleApproval(data, frame.delivery_id);
2124
+ : frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
2125
+ ? (codeHost.handleRestart?.(data, frame.delivery_id) ?? Promise.resolve(false))
2126
+ : codeHost.handleApproval(data, frame.delivery_id);
2119
2127
  // Acknowledged once handled, never before: a start acked and then
2120
2128
  // lost to a crash or a shutdown would leave its thread starting
2121
2129
  // forever. A start that ran — or failed, which it reports — is 200;
@@ -133,6 +133,8 @@ export interface HookContext {
133
133
  }
134
134
  /** Events for one Claude Code hook invocation. Pure apart from the context's readers. */
135
135
  export declare function claudeHookEvents(hook: string, input: Record<string, unknown>, ctx: HookContext): Promise<CodeThreadEvent[]>;
136
+ /** The Codex session (`thread-id`) a `notify` payload names, for `codex resume <id>`; `undefined` if none. */
137
+ export declare function codexThreadId(payload: string): string | undefined;
136
138
  /** Events for one Codex `notify` call; `payload` is the JSON Codex passes as the last argument. */
137
139
  export declare function codexNotifyEvents(payload: string, now: number): CodeThreadEvent[];
138
140
  /**
@@ -48,6 +48,7 @@ import { event, EVENT_POST_TIMEOUT_MS, postCodeThreadEvent, readHookConfig } fro
48
48
  import { awaitSettlement, beatenDeny, markTurnAnswered, recentPrompt, registerWaiter, settle, toolFingerprint } from './decision.js';
49
49
  import { INVOCATION_PROTOCOL_ENV, permissionInvocationIds, recordInvocation, supportsInvocationProtocol } from './invocations.js';
50
50
  import { markReady, readyPath } from './ready.js';
51
+ import { recordSession } from './session.js';
51
52
  import { codeHostDir, ensurePrivateDir, loadSettings, writePrivateJson } from './settings.js';
52
53
  import { isValidId } from './tmux.js';
53
54
  export const TERMINAL_ID_ENV = 'PROHOST_TERMINAL_ID';
@@ -426,6 +427,15 @@ export async function claudeHookEvents(hook, input, ctx) {
426
427
  }
427
428
  return [];
428
429
  }
430
+ /** The Codex session (`thread-id`) a `notify` payload names, for `codex resume <id>`; `undefined` if none. */
431
+ export function codexThreadId(payload) {
432
+ try {
433
+ return str(asObject(JSON.parse(payload))?.['thread-id']);
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ }
429
439
  /** Events for one Codex `notify` call; `payload` is the JSON Codex passes as the last argument. */
430
440
  export function codexNotifyEvents(payload, now) {
431
441
  let data;
@@ -680,6 +690,8 @@ export async function runHook(args, options = {}) {
680
690
  catch {
681
691
  input = {};
682
692
  }
693
+ // Which conversation this window runs: a restart resumes exactly it (`session.ts`).
694
+ recordSession(terminalId, 'claude_code', input.session_id, env);
683
695
  if (name === 'PermissionRequest') {
684
696
  // This host's settings ask the hook to wait for a decision (`--wait`). It waits only while
685
697
  // the owner allows remote control. A window launched before that (no `--wait`) keeps a
@@ -696,7 +708,9 @@ export async function runHook(args, options = {}) {
696
708
  events = await claudeHookEvents(name, input, ctx);
697
709
  }
698
710
  else if (kind === 'codex') {
699
- events = codexNotifyEvents(args[args.length - 1] ?? '', ctx.now());
711
+ const payload = args[args.length - 1] ?? '';
712
+ recordSession(env[TERMINAL_ID_ENV], 'codex', codexThreadId(payload), env);
713
+ events = codexNotifyEvents(payload, ctx.now());
700
714
  }
701
715
  }
702
716
  catch {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The Code-thread host inside `prohost agent run`: one object the harness
3
3
  * starts when this machine's owner has turned hosting on
4
- * (`prohost agent code-host enable`), and hands the three events it serves —
5
- * `code_thread.start_requested`, `.input_requested` and `.approval_decided`.
4
+ * (`prohost agent code-host enable`), and hands the events it serves —
5
+ * `code_thread.start_requested`, `.input_requested`, `.approval_decided` and `.restart_requested`.
6
6
  *
7
7
  * Off — the default — the harness never builds one: no tmux, no relay socket,
8
8
  * no local server, no host stats, and the events are acknowledged as ignored.
@@ -14,10 +14,11 @@ 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, StartRequest, TmuxRunner } from './terminals.js';
17
+ import type { AccountEnvResult, ApprovalRequest, CodeAgent, 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
+ export declare const EVENT_CODE_THREAD_RESTART_REQUESTED = "code_thread.restart_requested";
21
22
  /**
22
23
  * Declared in `runtime_status.capabilities` while this harness hosts threads.
23
24
  * Versioned with the contract the server relies on (`remote_terminal`, 409
@@ -52,10 +53,19 @@ export declare const CODE_THREAD_FOLDERS_CAPABILITY = "code_thread_folders";
52
53
  * server records a thread as skipping only for a socket that declared it.
53
54
  */
54
55
  export declare const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = "code_thread_skip_permissions";
56
+ /**
57
+ * Declared beside {@link CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY} (so only while remote control is
58
+ * on) by a host that handles `code_thread.restart_requested`: it relaunches a thread's Claude Code
59
+ * or Codex in the same window and folder, continuing its conversation, without permission prompts
60
+ * (`CodeTerminals.restart`). The server offers the restart only for a thread whose host said so.
61
+ */
62
+ export declare const CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY = "code_thread_restart_skip_permissions";
55
63
  /** `code_thread.start_requested` data (camelCase per the contract), or `null`. */
56
64
  export declare function parseStartRequest(data: Record<string, unknown>): StartRequest | null;
57
65
  /** `code_thread.input_requested` data, or `null`. */
58
66
  export declare function parseInputRequest(data: Record<string, unknown>): InputRequest | null;
67
+ /** `code_thread.restart_requested` data, or `null`. */
68
+ export declare function parseRestartRequest(data: Record<string, unknown>): RestartRequest | null;
59
69
  /** What the harness needs from a host; tests fake it. */
60
70
  export interface CodeHostLike {
61
71
  start(): Promise<void>;
@@ -68,6 +78,8 @@ export interface CodeHostLike {
68
78
  /** `deliveryId`: a redelivery of one already acted on is acknowledged without typing again. */
69
79
  handleInput(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
70
80
  handleApproval(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
81
+ /** `code_thread.restart_requested`; a host without it acks the event as ignored. */
82
+ handleRestart?(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
71
83
  latestStats(): HostStats | undefined;
72
84
  /** The owner's remote-terminal switch as it is on disk now. */
73
85
  remoteTerminalAllowed(): boolean;
@@ -115,8 +127,8 @@ export declare function parseApprovalDecision(data: Record<string, unknown>): Ap
115
127
  * server hands the reply or decision back instead of counting it delivered).
116
128
  */
117
129
  export declare class RefusedDeliveryError extends Error {
118
- readonly reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open';
119
- constructor(reason: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'shell_window' | 'decision_unconfirmed' | 'trust_prompt' | 'prompt_local' | 'prompt_open');
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');
120
132
  }
121
133
  export declare class UnreportedOutcomeError extends Error {
122
134
  constructor(codeThreadId: string);
@@ -300,6 +312,13 @@ export declare class CodeHost implements CodeHostLike {
300
312
  handleStart(data: Record<string, unknown>): Promise<boolean>;
301
313
  handleInput(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
302
314
  handleApproval(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
315
+ /**
316
+ * Relaunch the thread's agent without permission prompts, continuing its conversation
317
+ * (`CodeTerminals.restart`). Remote control of this Mac, so held to the switch at this moment
318
+ * (the harness lets it through `admits` for that); refused before anything is touched otherwise.
319
+ * A redelivery of one already done is acknowledged as done, without restarting again.
320
+ */
321
+ handleRestart(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
303
322
  /** The terminal's menu state (`menuState`) once a hook that is still deciding has had a moment to finish. */
304
323
  private menuSettled;
305
324
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The Code-thread host inside `prohost agent run`: one object the harness
3
3
  * starts when this machine's owner has turned hosting on
4
- * (`prohost agent code-host enable`), and hands the three events it serves —
5
- * `code_thread.start_requested`, `.input_requested` and `.approval_decided`.
4
+ * (`prohost agent code-host enable`), and hands the events it serves —
5
+ * `code_thread.start_requested`, `.input_requested`, `.approval_decided` and `.restart_requested`.
6
6
  *
7
7
  * Off — the default — the harness never builds one: no tmux, no relay socket,
8
8
  * no local server, no host stats, and the events are acknowledged as ignored.
@@ -29,6 +29,7 @@ import { APPROVAL_DECISIONS, isValidId, socketNameFor, TMUX_CONF } from './tmux.
29
29
  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
+ export const EVENT_CODE_THREAD_RESTART_REQUESTED = 'code_thread.restart_requested';
32
33
  /**
33
34
  * Declared in `runtime_status.capabilities` while this harness hosts threads.
34
35
  * Versioned with the contract the server relies on (`remote_terminal`, 409
@@ -63,6 +64,13 @@ export const CODE_THREAD_FOLDERS_CAPABILITY = 'code_thread_folders';
63
64
  * server records a thread as skipping only for a socket that declared it.
64
65
  */
65
66
  export const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_skip_permissions';
67
+ /**
68
+ * Declared beside {@link CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY} (so only while remote control is
69
+ * on) by a host that handles `code_thread.restart_requested`: it relaunches a thread's Claude Code
70
+ * or Codex in the same window and folder, continuing its conversation, without permission prompts
71
+ * (`CodeTerminals.restart`). The server offers the restart only for a thread whose host said so.
72
+ */
73
+ export const CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_restart_skip_permissions';
66
74
  function text(value) {
67
75
  return typeof value === 'string' ? value : undefined;
68
76
  }
@@ -97,6 +105,19 @@ export function parseInputRequest(data) {
97
105
  return null;
98
106
  return { codeThreadId, terminalId, text: value };
99
107
  }
108
+ /** `code_thread.restart_requested` data, or `null`. */
109
+ export function parseRestartRequest(data) {
110
+ const { codeThreadId, terminalId } = data;
111
+ if (!isValidId(codeThreadId) || !isValidId(terminalId))
112
+ return null;
113
+ const requestedAtMs = typeof data.requestedAtMs === 'number' && Number.isFinite(data.requestedAtMs) ? data.requestedAtMs : undefined;
114
+ return {
115
+ codeThreadId,
116
+ terminalId,
117
+ skipPermissions: data.skipPermissions === true,
118
+ ...(requestedAtMs !== undefined ? { requestedAtMs } : {}),
119
+ };
120
+ }
100
121
  /** `code_thread.approval_decided` data, or `null`. */
101
122
  export function parseApprovalDecision(data) {
102
123
  const { codeThreadId, terminalId, approvalId, decision } = data;
@@ -522,6 +543,10 @@ export class CodeHost {
522
543
  // reported failed by `handleStart`, never acked 204 and left `starting`.
523
544
  if (event === EVENT_CODE_THREAD_START_REQUESTED)
524
545
  return true;
546
+ // A restart checks the switch itself, after its ledger: a redelivery of one already done is
547
+ // acknowledged as done, never refused (the server would then say the agent still asks).
548
+ if (event === EVENT_CODE_THREAD_RESTART_REQUESTED)
549
+ return true;
525
550
  return settings.allowRemoteTerminal;
526
551
  }
527
552
  refusal(event) {
@@ -601,6 +626,59 @@ export class CodeHost {
601
626
  this.log(` ✓ code thread ${request.codeThreadId.slice(0, 12)}: ${request.decision} handed to its hook (approval ${request.approvalId.slice(0, 12)})`);
602
627
  return true;
603
628
  }
629
+ /**
630
+ * Relaunch the thread's agent without permission prompts, continuing its conversation
631
+ * (`CodeTerminals.restart`). Remote control of this Mac, so held to the switch at this moment
632
+ * (the harness lets it through `admits` for that); refused before anything is touched otherwise.
633
+ * A redelivery of one already done is acknowledged as done, without restarting again.
634
+ */
635
+ async handleRestart(data, deliveryId) {
636
+ const request = parseRestartRequest(data);
637
+ if (!request)
638
+ return false;
639
+ // A redelivery waits for any attempt still running (restarts are serialized per terminal), so it
640
+ // reads that attempt's real outcome below, never a claim taken a moment ago.
641
+ await this.terminals.settled(request.terminalId);
642
+ // Done already (its ack was lost): said as it ended, whatever the switch says now.
643
+ if (deliveryId && this.deliveries.has(deliveryId))
644
+ return this.repeat(deliveryId);
645
+ if (!this.settings().allowRemoteTerminal)
646
+ throw new RefusedDeliveryError('remote_terminal_disabled');
647
+ const outcome = {};
648
+ // Claimed right before the relaunch (`CodeTerminals.restart`), never before a check that may refuse
649
+ // or throw: a claim means the agent was replaced (or may have been), never just "seen".
650
+ const restarted = await this.terminals.restart(request, outcome, {
651
+ // Claimed as unconfirmed: until the window is seen running the new process, a repeat (this
652
+ // daemon died mid-relaunch, a resend) says so and is never answered "done".
653
+ claim: () => {
654
+ if (!deliveryId)
655
+ return true;
656
+ // One write: the claim never reaches disk without its "unconfirmed" mark, and a claim
657
+ // that can't be written relaunches nothing (a later daemon would not know it ran).
658
+ const claimed = this.deliveries.claimDurably(deliveryId, 'restart_unconfirmed');
659
+ return claimed === 'claimed' ? true : claimed === 'repeat' ? false : 'unsaved';
660
+ },
661
+ confirmed: () => {
662
+ if (deliveryId)
663
+ this.deliveries.confirm(deliveryId);
664
+ },
665
+ });
666
+ if (restarted)
667
+ return true;
668
+ if (outcome.repeat && deliveryId)
669
+ return this.repeat(deliveryId);
670
+ // Kept claimed (never relaunched twice) and said the same way on every redelivery.
671
+ if (outcome.refused === 'restart_unconfirmed' || outcome.refused === 'agent_exited') {
672
+ if (deliveryId)
673
+ this.deliveries.refuse(deliveryId, outcome.refused);
674
+ throw new RefusedDeliveryError(outcome.refused);
675
+ }
676
+ if (outcome.refused)
677
+ throw new RefusedDeliveryError(outcome.refused);
678
+ if (outcome.retry)
679
+ this.retryLater(outcome.release ? deliveryId : undefined, request.codeThreadId);
680
+ return false;
681
+ }
604
682
  /** The terminal's menu state (`menuState`) once a hook that is still deciding has had a moment to finish. */
605
683
  async menuSettled(terminalId) {
606
684
  if (!isValidId(terminalId))
@@ -643,8 +721,12 @@ export class CodeHost {
643
721
  */
644
722
  repeat(deliveryId) {
645
723
  const reason = this.deliveries.refusalFor(deliveryId);
646
- if (reason === 'reply_interrupted' || reason === 'decision_unconfirmed')
724
+ if (reason === 'reply_interrupted' ||
725
+ reason === 'decision_unconfirmed' ||
726
+ reason === 'restart_unconfirmed' ||
727
+ reason === 'agent_exited') {
647
728
  throw new RefusedDeliveryError(reason);
729
+ }
648
730
  return true;
649
731
  }
650
732
  }
@@ -21,8 +21,19 @@ export declare class DeliveryLedger {
21
21
  constructor(env?: NodeJS.ProcessEnv);
22
22
  /** Whether a prior attempt has an outcome to replay, without claiming a fresh delivery. */
23
23
  has(deliveryId: string): boolean;
24
- /** True the first time `deliveryId` is claimed (and records it); false for a repeat. */
25
- claim(deliveryId: string): boolean;
24
+ /**
25
+ * True the first time `deliveryId` is claimed (and records it); false for a repeat.
26
+ * `refusal` is recorded with the claim in the same write, so no crash or failed write
27
+ * can leave the claim on disk without it (a repeat would then read as done).
28
+ */
29
+ claim(deliveryId: string, refusal?: string): boolean;
30
+ /**
31
+ * {@link claim} for an act that must not run twice across a daemon restart (a relaunch):
32
+ * `'unsaved'` when the claim could not be written, and then it is not kept either, so the
33
+ * caller does nothing and the delivery is sent again rather than acted on unrecorded.
34
+ */
35
+ claimDurably(deliveryId: string, refusal?: string): 'claimed' | 'repeat' | 'unsaved';
36
+ private record;
26
37
  /**
27
38
  * Forget a claim that typed nothing (refused before any key): a redelivery is
28
39
  * judged afresh, so a refusal whose ack was lost is never answered "done".
@@ -30,6 +41,9 @@ export declare class DeliveryLedger {
30
41
  release(deliveryId: string): void;
31
42
  /** A claimed delivery that may have typed something and was then refused: a repeat refuses it the same way. */
32
43
  refuse(deliveryId: string, reason: string): void;
44
+ /** A claimed delivery's outcome is known good after all: a repeat answers it as done. */
45
+ confirm(deliveryId: string): void;
33
46
  refusalFor(deliveryId: string): string | undefined;
47
+ /** False when the write failed: in memory it still dedupes for this process. */
34
48
  private save;
35
49
  }
@@ -40,20 +40,49 @@ export class DeliveryLedger {
40
40
  has(deliveryId) {
41
41
  return this.known.has(deliveryId);
42
42
  }
43
- /** True the first time `deliveryId` is claimed (and records it); false for a repeat. */
44
- claim(deliveryId) {
43
+ /**
44
+ * True the first time `deliveryId` is claimed (and records it); false for a repeat.
45
+ * `refusal` is recorded with the claim in the same write, so no crash or failed write
46
+ * can leave the claim on disk without it (a repeat would then read as done).
47
+ */
48
+ claim(deliveryId, refusal) {
49
+ return this.record(deliveryId, refusal, false) !== 'repeat';
50
+ }
51
+ /**
52
+ * {@link claim} for an act that must not run twice across a daemon restart (a relaunch):
53
+ * `'unsaved'` when the claim could not be written, and then it is not kept either, so the
54
+ * caller does nothing and the delivery is sent again rather than acted on unrecorded.
55
+ */
56
+ claimDurably(deliveryId, refusal) {
57
+ return this.record(deliveryId, refusal, true);
58
+ }
59
+ record(deliveryId, refusal, durable) {
45
60
  if (!deliveryId || this.known.has(deliveryId))
46
- return false;
61
+ return 'repeat';
62
+ const before = { ids: [...this.ids], refused: new Map(this.refused) };
47
63
  this.known.add(deliveryId);
48
64
  this.ids.push(deliveryId);
65
+ if (refusal)
66
+ this.refused.set(deliveryId, refusal);
49
67
  if (this.ids.length > MAX_REMEMBERED_DELIVERIES) {
50
68
  for (const dropped of this.ids.splice(0, this.ids.length - MAX_REMEMBERED_DELIVERIES)) {
51
69
  this.known.delete(dropped);
52
70
  this.refused.delete(dropped);
53
71
  }
54
72
  }
55
- this.save();
56
- return true;
73
+ if (this.save())
74
+ return 'claimed';
75
+ // Kept in memory it still dedupes this process; a durable claim is undone instead.
76
+ if (!durable)
77
+ return 'claimed';
78
+ this.ids = before.ids;
79
+ this.known.clear();
80
+ for (const id of this.ids)
81
+ this.known.add(id);
82
+ this.refused.clear();
83
+ for (const [id, reason] of before.refused)
84
+ this.refused.set(id, reason);
85
+ return 'unsaved';
57
86
  }
58
87
  /**
59
88
  * Forget a claim that typed nothing (refused before any key): a redelivery is
@@ -73,9 +102,16 @@ export class DeliveryLedger {
73
102
  this.refused.set(deliveryId, reason);
74
103
  this.save();
75
104
  }
105
+ /** A claimed delivery's outcome is known good after all: a repeat answers it as done. */
106
+ confirm(deliveryId) {
107
+ if (!this.refused.delete(deliveryId))
108
+ return;
109
+ this.save();
110
+ }
76
111
  refusalFor(deliveryId) {
77
112
  return this.refused.get(deliveryId);
78
113
  }
114
+ /** False when the write failed: in memory it still dedupes for this process. */
79
115
  save() {
80
116
  try {
81
117
  writePrivateJson(deliveryLedgerPath(this.env), {
@@ -83,9 +119,10 @@ export class DeliveryLedger {
83
119
  ids: this.ids,
84
120
  refused: Object.fromEntries(this.refused),
85
121
  });
122
+ return true;
86
123
  }
87
124
  catch {
88
- /* in memory it still dedupes for this process */
125
+ return false;
89
126
  }
90
127
  }
91
128
  }
@@ -16,16 +16,15 @@ export declare function readyPath(terminalId: string, env?: NodeJS.ProcessEnv):
16
16
  */
17
17
  export declare function markReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
18
18
  /**
19
- * Whether Claude Code ran a session in exactly `cwd`, under `configDir`, that it last wrote at or
20
- * after `sinceMs`: a `projects/<key>/<session>.jsonl` modified since then whose entries name `cwd`
21
- * itself (read from inside the file: the dash-encoded `<key>` is lossy, `/a-b` and `/a/b` share it).
22
- *
23
- * Claude Code writes that transcript only once the folder is trusted (checked against 2.1.314:
24
- * nothing under `projects/` while the dialog is up; the transcript the moment it is accepted and
25
- * the first prompt runs). With `sinceMs` the window's start, it is proof, from a file Claude Code
26
- * owns, that this window's Claude Code got past its dialog. Used only for a window an older build
27
- * started, which never had a ready mark.
19
+ * Forget the mark: the window's Claude Code was replaced (a restart), and the new one is past its
20
+ * dialog only once its own first hook runs. Returns whether no mark is left.
21
+ */
22
+ export declare function clearReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
23
+ /**
24
+ * The transcript files (`<session>.jsonl`) Claude Code keeps for `cwd` under `configDir`: one exists
25
+ * for a session only once its first prompt ran. Empty when there are none or they can't be read.
28
26
  */
27
+ export declare function claudeTranscripts(cwd: string, configDir: string): string[];
29
28
  export declare function claudeSessionSince(cwd: string, configDir: string, sinceMs: number): boolean;
30
29
  /** `true` past the dialog, `false` not yet, `undefined` when the state can't be read. */
31
30
  export declare function isReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean | undefined;