@prohost/cli 0.16.3 → 0.16.5

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.
@@ -20,26 +20,31 @@
20
20
  * is the thread already started.
21
21
  */
22
22
  import { execFile } from 'node:child_process';
23
+ import { randomBytes } from 'node:crypto';
23
24
  import { closeSync, openSync, utimesSync } from 'node:fs';
24
25
  import os from 'node:os';
25
26
  import path from 'node:path';
26
27
  import { shellQuote } from '../agent/shell.js';
27
28
  import { worktreesRoot } from '../agent/worktrees.js';
28
29
  import { markTurn } from './activity.js';
30
+ import { markTurnAnswered } from './decision.js';
29
31
  import { missingAgentReason } from './agents.js';
30
32
  import { claudeConfigDir, claudeTrusts, trustClaudeWorkspace } from './claude_trust.js';
31
33
  import { event } from './events.js';
32
34
  import { readHistory } from './history.js';
35
+ import { loginOf, readSession } from './session.js';
33
36
  import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
34
37
  import { MAX_OPEN_PTYS } from './pty.js';
35
- import { claudeSessionSince, isReady, markReady } from './ready.js';
38
+ import { claudeSessionSince, claudeTranscripts, clearReady, isReady, markReady } from './ready.js';
36
39
  import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
37
40
  import { noteInputTakeover } from './sizing.js';
38
- import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.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';
39
42
  export const CODE_AGENTS = ['claude_code', 'codex', 'shell'];
40
43
  export const GOAL_ENV = 'PROHOST_CODE_THREAD_GOAL';
41
44
  /** Window option set when the agent exits and its window becomes a shell. */
42
45
  export const EXITED_OPTION = '@prohost_exited';
46
+ /** Window option a relaunched pane stamps with its relaunch's token (`CodeTerminals.restart`). */
47
+ export const RELAUNCH_OPTION = '@prohost_relaunch';
43
48
  /**
44
49
  * Window option that marks a relayed reply as possibly sitting in the agent's
45
50
  * input unsubmitted. It is set before the reply's first keystroke reaches the
@@ -77,6 +82,33 @@ export function execTmux(binary = 'tmux') {
77
82
  }
78
83
  });
79
84
  }
85
+ /** What a restart that found no conversation to resume says on its thread. */
86
+ export function freshConversationNote(agent) {
87
+ const label = agent === 'codex' ? 'Codex' : 'Claude Code';
88
+ return `${label} restarted in a new conversation: its earlier one wasn't found on this Mac.`;
89
+ }
90
+ /** Shells a thread window falls back to (`exec "${SHELL:-/bin/zsh}" -l`), as `#{pane_current_command}` names them. */
91
+ const SHELL_COMMANDS = new Set(['sh', 'zsh', 'bash', 'fish', 'dash', 'ksh', 'tcsh', 'csh', 'nu']);
92
+ /** Whether a pane's current command is a shell (a login shell's `-zsh` too). */
93
+ export function isShellCommand(command) {
94
+ return SHELL_COMMANDS.has(path.basename(command.replace(/^-/, '')));
95
+ }
96
+ /**
97
+ * How long a relaunched agent is watched for quitting at once before its restart is confirmed: a
98
+ * missing binary or a failed resume exits within a second or two.
99
+ */
100
+ export const RESTART_SETTLE_POLLS = 6;
101
+ /** How long a relaunched pane may take to stamp its token (wall clock, from the relaunch). */
102
+ export const RESTART_STAMP_WAIT_MS = 10_000;
103
+ export const RESTART_SETTLE_POLL_MS = 500;
104
+ /** How many times a restart's `stop` is posted, and the first pause between tries (doubling). */
105
+ export const RESTART_STOP_ATTEMPTS = 3;
106
+ export const RESTART_STOP_BACKOFF_MS = 500;
107
+ /** Then, in the background, this many more rounds, the n-th after n × the pause. */
108
+ export const RESTART_STOP_LATER_ATTEMPTS = 4;
109
+ export const RESTART_STOP_LATER_PAUSE_MS = 15_000;
110
+ /** How old a restart may be when it reaches this Mac (the server's `RESTART_MAX_AGE`). */
111
+ export const RESTART_MAX_AGE_MS = 10 * 60_000;
80
112
  /**
81
113
  * Whether an event post's status settles it (see {@link StartResult}): the
82
114
  * server applied it (2xx) or will refuse it the same way every time (404, 409,
@@ -96,12 +128,20 @@ export const CLAUDE_SKIP_PERMISSIONS_FLAG = '--dangerously-skip-permissions';
96
128
  * `-a never` alone would keep the sandbox, where a command needing the network fails instead of asking.
97
129
  */
98
130
  export const CODEX_SKIP_PERMISSIONS_FLAG = '--dangerously-bypass-approvals-and-sandbox';
131
+ /** Claude Code's (`claude --help`, 2.1.318): "Continue the most recent conversation in the current directory". */
132
+ export const CLAUDE_CONTINUE_FLAG = '--continue';
133
+ /**
134
+ * Codex's (`codex resume --help`, 0.146): the most recent session without the picker, filtered to
135
+ * the current folder (`--all` would lift that). Its own options (`-c`, the skip flag) follow it.
136
+ */
137
+ export const CODEX_RESUME_ARGS = 'resume --last';
99
138
  /** The shell command line a thread's agent window runs. */
100
139
  export function agentCommandLine(agent, options) {
140
+ const resume = options.resume;
101
141
  // `--` first: a goal is the agent's prompt, never an option, however it
102
142
  // starts (`--dangerously-skip-permissions`, or a word Codex would read as a
103
143
  // subcommand).
104
- const goal = options.hasGoal ? ` -- "$${GOAL_ENV}"` : '';
144
+ const goal = options.hasGoal && !resume ? ` -- "$${GOAL_ENV}"` : '';
105
145
  const skip = options.skipPermissions === true;
106
146
  const shell = 'exec "${SHELL:-/bin/zsh}" -l';
107
147
  if (agent === 'shell')
@@ -113,10 +153,12 @@ export function agentCommandLine(agent, options) {
113
153
  const after = `tmux set-option -w ${EXITED_OPTION} 1 2>/dev/null; ` +
114
154
  `printf '\\n[${label} exited — this window is now a shell in the thread worktree]\\n'; ${shell}`;
115
155
  if (agent === 'codex') {
116
- const flags = skip ? ` ${CODEX_SKIP_PERMISSIONS_FLAG}` : '';
156
+ const resumeArgs = resume === undefined ? '' : resume === 'latest' ? ` ${CODEX_RESUME_ARGS}` : ` resume ${shellQuote(resume, 'darwin')}`;
157
+ const flags = `${resumeArgs}${skip ? ` ${CODEX_SKIP_PERMISSIONS_FLAG}` : ''}`;
117
158
  return `codex${flags} -c ${shellQuote(options.codexNotify, 'darwin')}${goal}; ${after}`;
118
159
  }
119
- const claude = `command claude${skip ? ` ${CLAUDE_SKIP_PERMISSIONS_FLAG}` : ''} --settings`;
160
+ const resumeFlag = resume === undefined ? '' : resume === 'latest' ? ` ${CLAUDE_CONTINUE_FLAG}` : ` --resume ${shellQuote(resume, 'darwin')}`;
161
+ const claude = `command claude${resumeFlag}${skip ? ` ${CLAUDE_SKIP_PERMISSIONS_FLAG}` : ''} --settings`;
120
162
  // A window that skips prompts reads the documents that also accept Claude Code's bypass warning.
121
163
  const settings = (skip && options.claudeSkipSettings) || options.claudeSettings;
122
164
  const invocationSettings = (skip && options.claudeInvocationSkipSettings) || options.claudeInvocationSettings;
@@ -187,6 +229,10 @@ export class CodeTerminals {
187
229
  /** Per terminal: the tail of its typing queue (see {@link serialize}). */
188
230
  typing = new Map();
189
231
  openPtys = 0;
232
+ /** The last `adopt` look timed out (`TmuxResult.uncertain`): records were kept, not re-read. */
233
+ lastListUncertain = false;
234
+ /** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
235
+ panePids = new Map();
190
236
  /** Who sizes each window, and what its viewers are told (`sizing.ts`). */
191
237
  sizing;
192
238
  log;
@@ -302,6 +348,10 @@ export class CodeTerminals {
302
348
  }
303
349
  async adopt() {
304
350
  const listed = await this.tmux(listWindowsArgs());
351
+ // A look tmux didn't answer in time says nothing about what survived: what is known stays.
352
+ this.lastListUncertain = !listed.ok && listed.uncertain === true;
353
+ if (this.lastListUncertain)
354
+ return this.records.size;
305
355
  if (!listed.ok) {
306
356
  // No server or no session: nothing survived, and nothing is wrong.
307
357
  this.records.clear();
@@ -327,6 +377,7 @@ export class CodeTerminals {
327
377
  this.records.clear();
328
378
  for (const [id, record] of found)
329
379
  this.records.set(id, record);
380
+ this.panePids = panes;
330
381
  // Once per daemon start: the windows an older build left behind are all there now. Started by
331
382
  // the first call; every call waits for it, so none returns before those windows are marked. A
332
383
  // pass that fails (a keychain still locked right after login) never fails an adopt: it is
@@ -576,6 +627,319 @@ export class CodeTerminals {
576
627
  await this.sleep(TRUST_POLL_MS);
577
628
  }
578
629
  }
630
+ /**
631
+ * Relaunch a thread's Claude Code or Codex in its own window and folder, continuing its
632
+ * conversation (`code_thread.restart_requested`): the process in the agent window is replaced
633
+ * (`respawn-pane -k`), so the window, its viewers and its size stay. Run with
634
+ * {@link RestartRequest.skipPermissions}, it no longer asks before running commands, as a start
635
+ * that skips prompts (the same flags and `--settings` documents). Whatever the old one was doing
636
+ * stops: the person asked for that, from the thread they own.
637
+ *
638
+ * It resumes the session the window's hooks recorded (`session.ts`); a worktree of the thread's
639
+ * own may fall back to its latest session.
640
+ *
641
+ * Refused (nothing is touched, the server says so on the thread) with remote control off
642
+ * (`remote_terminal_disabled`: as for a start, nothing from ProhostAI starts an agent that runs
643
+ * commands unasked then), for a thread whose window is gone (`terminal_gone`), for a shell
644
+ * thread (`shell_window`), or while its session isn't known in a folder it shares
645
+ * (`session_unknown`). An agent that exited into its shell is relaunched all the same.
646
+ * Serialized with the thread's replies, so it never lands between a paste and its Enter.
647
+ */
648
+ async restart(request, outcome = {}, hooks = {}) {
649
+ if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId))
650
+ return false;
651
+ return this.serialize(request.terminalId, () => this.restartNow(request, outcome, hooks));
652
+ }
653
+ /** Once every typing job and restart already queued for the terminal has finished (never rejects). */
654
+ async settled(terminalId) {
655
+ await (this.typing.get(terminalId) ?? Promise.resolve()).catch(() => undefined);
656
+ }
657
+ /** The agent window's pane process as tmux says now; `undefined` when it didn't answer or has none. */
658
+ async panePidNow(terminalId) {
659
+ const listed = await this.tmux(listWindowsArgs());
660
+ if (!listed.ok)
661
+ return undefined;
662
+ const name = windowName(terminalId, 'agent');
663
+ return parseListWindows(listed.stdout).find((window) => window.name === name)?.panePid;
664
+ }
665
+ /** Whether the thread's folder is a worktree this host made for threads (never one of the owner's own). */
666
+ ownsFolder(record) {
667
+ const relative = path.relative(worktreesRoot({ PROHOST_HOME: this.options.home }), record.cwd);
668
+ return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
669
+ }
670
+ async restartNow(request, outcome, hooks) {
671
+ const short = request.codeThreadId.slice(0, 12);
672
+ const refuse = (reason, why) => {
673
+ this.log(` ! code thread ${short} not restarted: ${why}`);
674
+ outcome.refused = reason;
675
+ return false;
676
+ };
677
+ // Too late to stop a turn nobody expects to stop (the Mac was asleep or offline).
678
+ if (request.requestedAtMs !== undefined && this.now() - request.requestedAtMs > RESTART_MAX_AGE_MS) {
679
+ return refuse('restart_expired', 'it arrived too late');
680
+ }
681
+ // Re-read tmux first: a window closed since the last look is gone, not retried. A look that timed
682
+ // out says nothing either way (the window may well be there): sent again later.
683
+ await this.adopt();
684
+ if (this.lastListUncertain) {
685
+ this.log(` ! code thread ${short} not restarted yet: tmux did not answer`);
686
+ outcome.retry = true;
687
+ return false;
688
+ }
689
+ const record = this.records.get(request.terminalId);
690
+ if (!record || record.codeThreadId !== request.codeThreadId)
691
+ return refuse('terminal_gone', 'its terminal is gone');
692
+ if (record.agent !== 'claude_code' && record.agent !== 'codex')
693
+ return refuse('shell_window', 'it is a shell');
694
+ if (request.skipPermissions && !this.canType())
695
+ return refuse('remote_terminal_disabled', 'remote control is off');
696
+ const agent = record.agent;
697
+ const account = this.options.accountEnv?.(agent) ?? { ok: true, env: {} };
698
+ if (!account.ok)
699
+ return refuse('login_unavailable', account.reason);
700
+ const launchEnv = { ...(this.options.processEnv ?? process.env), ...agentWindowEnv(agent, account.env) };
701
+ const recorded = readSession(record.terminalId, { PROHOST_HOME: this.options.home });
702
+ // Never a silent switch to another login: the conversation lives under the one it ran on.
703
+ if (recorded && recorded.login !== loginOf(agent, launchEnv)) {
704
+ return refuse('login_changed', `its session is on another ${agent} login than this Mac runs now`);
705
+ }
706
+ const resume = this.resumeTarget(agent, record, recorded?.id, launchEnv);
707
+ if (resume === null)
708
+ return refuse('session_unknown', `which ${agent} session its window runs isn't known yet`);
709
+ const name = windowName(record.terminalId, 'agent');
710
+ const target = windowTarget(name);
711
+ const env = { PROHOST_HOME: this.options.home };
712
+ // From inside the pane, before the agent: an agent that dies at once marks its window a shell
713
+ // again (`agentCommandLine`'s `after`), never after this clears it.
714
+ // The pane itself, once the new process really runs and before the agent: lifts the exited mark of
715
+ // whatever ran before and stamps this relaunch's token. The daemon never lifts the mark itself (a
716
+ // window that is still a shell must keep refusing replies), and trusts a mark only after seeing the
717
+ // token (`exitsAtOnce`): a stale mark can never read as the new agent quitting.
718
+ const token = randomBytes(8).toString('hex');
719
+ const command = `tmux set-option -wu ${EXITED_OPTION} 2>/dev/null; tmux set-option -w ${RELAUNCH_OPTION} ${token} 2>/dev/null; ` +
720
+ agentCommandLine(agent, { ...this.options.hookFiles, hasGoal: false, skipPermissions: request.skipPermissions, resume });
721
+ // Like a new window: replies wait for the new Claude Code's first hook (`ready.ts`). Dropped before
722
+ // the old process goes, so that hook's mark is never wiped after it was written.
723
+ // The switch once more, as late as it can be: nothing awaits between here and `respawn-pane`, so a
724
+ // `remote off` that landed during the looks above stops it before anything is claimed or touched.
725
+ if (request.skipPermissions && !this.canType())
726
+ return refuse('remote_terminal_disabled', 'remote control went off');
727
+ // Claimed only now, past every refusal: a redelivery after any earlier failure is judged afresh,
728
+ // and one queued behind this attempt sees its real outcome (`CodeHost.handleRestart`).
729
+ const claimed = hooks.claim ? hooks.claim() : true;
730
+ if (claimed === 'unsaved') {
731
+ outcome.retry = true;
732
+ return false;
733
+ }
734
+ if (!claimed) {
735
+ outcome.repeat = true;
736
+ return false;
737
+ }
738
+ const before = this.panePids.get(record.terminalId);
739
+ const wasReady = agent === 'claude_code' && this.isReady(record.terminalId) === true;
740
+ if (agent === 'claude_code')
741
+ clearReady(record.terminalId, env);
742
+ // The restart's own `stop` is stamped now, before the new pane exists: any hook the new agent
743
+ // sends is strictly newer and wins under the server's host-event ordering, never this one.
744
+ const stopAt = this.now();
745
+ const respawned = await this.tmux(respawnPaneArgs({
746
+ name,
747
+ cwd: record.cwd,
748
+ env: {
749
+ ...agentWindowEnv(agent, account.env),
750
+ PROHOST_HOME: this.options.home,
751
+ [TERMINAL_ID_ENV]: record.terminalId,
752
+ [CODE_THREAD_ID_ENV]: record.codeThreadId,
753
+ },
754
+ command,
755
+ }));
756
+ if (!respawned.ok && !respawned.uncertain) {
757
+ // Nothing was replaced: the old process (and its readiness) stays, and it is worth sending again.
758
+ if (wasReady)
759
+ markReady(record.terminalId, env);
760
+ this.log(` ! code thread ${short} not restarted: tmux said ${respawned.stderr.trim()}`);
761
+ outcome.retry = true;
762
+ outcome.release = true;
763
+ return false;
764
+ }
765
+ // Done only once the window is seen running a new process (its pane's pid changed) and the agent
766
+ // still runs in it a moment later. tmux saying yes (or timing out) is not enough to tell the server
767
+ // prompts are off.
768
+ const after = await this.panePidNow(record.terminalId);
769
+ if (after !== undefined && after === before) {
770
+ // The same process still runs: nothing was replaced. It keeps its readiness, and is sent again.
771
+ if (wasReady)
772
+ markReady(record.terminalId, env);
773
+ this.log(` ! code thread ${short} not restarted: its window still runs the same process`);
774
+ outcome.retry = true;
775
+ outcome.release = true;
776
+ return false;
777
+ }
778
+ const replaced = after !== undefined && before !== undefined;
779
+ if (!replaced && !respawned.ok) {
780
+ // Can't be told (tmux timed out and didn't answer the look): the old process may still run, so
781
+ // its state stays as it was. Never done twice, never reported done.
782
+ this.log(` ! code thread ${short}: couldn't confirm ${agent} restarted; it may or may not have`);
783
+ outcome.refused = 'restart_unconfirmed';
784
+ return false;
785
+ }
786
+ if (after !== undefined)
787
+ this.panePids.set(record.terminalId, after);
788
+ // The old process is gone (seen, or tmux said so): nothing of an old reply left in its input, no
789
+ // permission menu of it open, not mid-turn, and the thread is the person's turn.
790
+ await this.tmux(['set-option', '-wu', '-t', target, STAGED_OPTION]);
791
+ markTurnAnswered(record.terminalId, env, this.now);
792
+ markTurn(record.terminalId, false, env);
793
+ if (agent === 'claude_code' && !record.readyMark) {
794
+ // This build's mark governs it from now on.
795
+ for (const args of setWindowMetaArgs(name, { ...record, readyMark: true }))
796
+ await this.tmux(args);
797
+ this.records.set(record.terminalId, { ...record, readyMark: true });
798
+ }
799
+ const exit = replaced ? await this.exitsAtOnce(target, token) : 'running';
800
+ // A session whose conversation wasn't found started a new one, and the thread says so (never
801
+ // "continues the same conversation" silently).
802
+ const fresh = resume === undefined && recorded !== undefined && replaced && exit === 'running';
803
+ // One event, built once: every try (and every later one) sends the same body and stamp, so the
804
+ // server applies it once (a repeat of a stamp it has is a redelivery) and a newer hook event of
805
+ // the new agent is never overtaken by it.
806
+ const stop = event('stop', fresh ? { finalText: freshConversationNote(agent), question: true } : { question: true }, stopAt);
807
+ if (!replaced || exit !== 'running') {
808
+ // Not a confirmed relaunch: said to the server for what it is (below). Its turn state is tried
809
+ // once before that refusal, then the same event again in the background: a window that is a
810
+ // shell now sends no hook of its own that would correct it.
811
+ const status = await this.options.events.post(record.codeThreadId, stop);
812
+ if ((status < 200 || status >= 300) && !isDefinitive(status)) {
813
+ void this.postRestartStopLater(record.codeThreadId, stop);
814
+ }
815
+ }
816
+ if (!replaced) {
817
+ // tmux said it relaunched, but the window couldn't be looked at: not reported done.
818
+ this.log(` ! code thread ${short}: ${agent} relaunched, but its window couldn't be checked`);
819
+ outcome.refused = 'restart_unconfirmed';
820
+ return false;
821
+ }
822
+ if (exit === 'exited') {
823
+ // A new process, but the agent quit at once (a missing binary, a resume that failed), and the
824
+ // pane says it runs a shell: the window is its shell now. Never reported as an agent at all.
825
+ this.log(` ! code thread ${short}: ${agent} exited right after restarting; its window is a shell`);
826
+ outcome.refused = 'agent_exited';
827
+ return false;
828
+ }
829
+ if (exit === 'unclear') {
830
+ // The signals disagree: it may well be running without prompts. Never said to be a shell, never
831
+ // said done: the server keeps it asking and says it may run without asking.
832
+ this.log(` ! code thread ${short}: couldn't tell whether ${agent} still runs after restarting`);
833
+ outcome.refused = 'restart_unconfirmed';
834
+ return false;
835
+ }
836
+ // Confirmed: the agent runs without prompts in that window. Recorded as done first (the ledger:
837
+ // no redelivery relaunches it again, whatever the post below does), and acknowledged done, so the
838
+ // server records it and never offers it again.
839
+ hooks.confirmed?.();
840
+ if (!(await this.postRestartStop(record.codeThreadId, stop))) {
841
+ // Only the thread's turn state is behind (the agent's own next hook corrects it too): the same
842
+ // event keeps being sent a while longer, never a relaunch.
843
+ this.log(` ! code thread ${short}: ${agent} restarted; its waiting state hasn't reached ProhostAI yet, retrying`);
844
+ void this.postRestartStopLater(record.codeThreadId, stop);
845
+ }
846
+ this.log(` ↻ code thread ${short}: ${agent} restarted${request.skipPermissions ? ' without permission prompts' : ''}`);
847
+ if (agent === 'claude_code') {
848
+ void this.watchTrustPrompt(record.codeThreadId, record.terminalId, name, request.skipPermissions);
849
+ }
850
+ return true;
851
+ }
852
+ /**
853
+ * Whether a just-relaunched agent quits within a moment. First waits (until {@link RESTART_STAMP_WAIT_MS}
854
+ * have passed) for the window to carry this relaunch's `token` ({@link RELAUNCH_OPTION}, stamped by the new
855
+ * process's own pane after it lifted the old exited mark); only then is {@link EXITED_OPTION} read as
856
+ * the new agent's, over {@link RESTART_SETTLE_POLLS} more looks from that moment. `exited` only when that mark and the pane running a shell (`#{pane_current_command}`)
857
+ * agree; `running` only when the token was seen and the last look was answered without the mark;
858
+ * anything else (never seen, a look unanswered, the signals disagreeing) is `unclear`.
859
+ */
860
+ async exitsAtOnce(target, token) {
861
+ // First the stamp, in its own bounded wait: a login shell's start-up (`.zshenv`, a Mac just awake)
862
+ // can take seconds before the pane's prefix runs.
863
+ // A wall-clock bound: a look tmux is slow to answer counts against it too.
864
+ const deadline = this.now() + RESTART_STAMP_WAIT_MS;
865
+ let stamped = false;
866
+ while (!stamped && this.now() < deadline) {
867
+ await this.sleep(RESTART_SETTLE_POLL_MS);
868
+ const stamp = await this.tmux(['show-options', '-wqv', '-t', target, RELAUNCH_OPTION]);
869
+ stamped = stamp.ok && stamp.stdout.trim() === token;
870
+ }
871
+ if (!stamped)
872
+ return 'unclear';
873
+ // Then the agent gets its full settle time from that moment, whenever the stamp came.
874
+ let lastAnswered = false;
875
+ for (let look = 0; look < RESTART_SETTLE_POLLS; look += 1) {
876
+ await this.sleep(RESTART_SETTLE_POLL_MS);
877
+ lastAnswered = false;
878
+ const stamp = await this.tmux(['show-options', '-wqv', '-t', target, RELAUNCH_OPTION]);
879
+ // Another relaunch's stamp, or no answer: nothing can be read from the mark.
880
+ if (!stamp.ok || stamp.stdout.trim() !== token)
881
+ continue;
882
+ const shown = await this.tmux(['show-options', '-wqv', '-t', target, EXITED_OPTION]);
883
+ if (!shown.ok)
884
+ continue;
885
+ lastAnswered = true;
886
+ if (shown.stdout.trim() !== '1')
887
+ continue;
888
+ const command = await this.tmux(['display-message', '-p', '-t', target, '#{pane_current_command}']);
889
+ if (!command.ok)
890
+ return 'unclear';
891
+ return isShellCommand(command.stdout.trim()) ? 'exited' : 'unclear';
892
+ }
893
+ return lastAnswered ? 'running' : 'unclear';
894
+ }
895
+ /**
896
+ * The restart's `stop` (the person's turn), tried {@link RESTART_STOP_ATTEMPTS} times with a
897
+ * growing pause: whether ProhostAI took it (2xx). A restart is acknowledged done only after that.
898
+ */
899
+ async postRestartStop(codeThreadId, stop) {
900
+ for (let attempt = 0; attempt < RESTART_STOP_ATTEMPTS; attempt += 1) {
901
+ if (attempt > 0)
902
+ await this.sleep(RESTART_STOP_BACKOFF_MS * 2 ** (attempt - 1));
903
+ const status = await this.options.events.post(codeThreadId, stop);
904
+ if (status >= 200 && status < 300)
905
+ return true;
906
+ // An answer it will keep giving (a 4xx other than 401/403/408/429): trying again changes nothing.
907
+ if (isDefinitive(status))
908
+ return false;
909
+ }
910
+ return false;
911
+ }
912
+ /** The same `stop`, sent again a few times over a few minutes, in the background; never throws. */
913
+ async postRestartStopLater(codeThreadId, stop) {
914
+ try {
915
+ for (let attempt = 0; attempt < RESTART_STOP_LATER_ATTEMPTS; attempt += 1) {
916
+ await this.sleep(RESTART_STOP_LATER_PAUSE_MS * (attempt + 1));
917
+ if (await this.postRestartStop(codeThreadId, stop))
918
+ return;
919
+ }
920
+ }
921
+ catch {
922
+ /* the agent's own next hook says whose turn it is */
923
+ }
924
+ }
925
+ /**
926
+ * What a restart resumes (`agentCommandLine`'s `resume`): the recorded session when its
927
+ * conversation exists; `'latest'` in a worktree of the thread's own; `undefined` (a fresh start)
928
+ * when there is no conversation yet; `null` when it can't be told (a folder of the owner's, its
929
+ * session never reported). Claude Code writes a session's transcript only once its first prompt
930
+ * runs (`ready.ts`), so a session id from `SessionStart` alone has nothing to resume. Codex reports
931
+ * its id only after a turn (`notify`), so a recorded one has.
932
+ */
933
+ resumeTarget(agent, record, sessionId, launchEnv) {
934
+ if (agent === 'codex')
935
+ return sessionId ?? (this.ownsFolder(record) ? 'latest' : null);
936
+ const transcripts = claudeTranscripts(record.cwd, claudeConfigDir(launchEnv));
937
+ if (sessionId)
938
+ return transcripts.includes(`${sessionId}.jsonl`) ? sessionId : undefined;
939
+ if (!this.ownsFolder(record))
940
+ return null;
941
+ return transcripts.length > 0 ? 'latest' : undefined;
942
+ }
579
943
  async known(terminalId) {
580
944
  if (!isValidId(terminalId))
581
945
  return undefined;
@@ -63,6 +63,12 @@ export interface NewWindowSpec {
63
63
  command: string;
64
64
  }
65
65
  export declare function newWindowArgs(spec: NewWindowSpec): string[];
66
+ /**
67
+ * Replace the process in a window's pane (`respawn-pane -k`: the one running is killed) with
68
+ * `command`, in `cwd` and with `env` as {@link newWindowArgs} gives them: the same window (its name,
69
+ * options, size and viewers) runs something new.
70
+ */
71
+ export declare function respawnPaneArgs(spec: NewWindowSpec): string[];
66
72
  /** Window options that let a restarted daemon re-adopt a thread. */
67
73
  export interface WindowMeta {
68
74
  terminalId: string;
@@ -128,6 +128,15 @@ export function newWindowArgs(spec) {
128
128
  const env = Object.entries(spec.env).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
129
129
  return ['new-window', '-d', '-t', `${TMUX_SESSION}:`, '-n', spec.name, '-c', spec.cwd, ...env, spec.command];
130
130
  }
131
+ /**
132
+ * Replace the process in a window's pane (`respawn-pane -k`: the one running is killed) with
133
+ * `command`, in `cwd` and with `env` as {@link newWindowArgs} gives them: the same window (its name,
134
+ * options, size and viewers) runs something new.
135
+ */
136
+ export function respawnPaneArgs(spec) {
137
+ const env = Object.entries(spec.env).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
138
+ return ['respawn-pane', '-k', '-t', windowTarget(spec.name), '-c', spec.cwd, ...env, spec.command];
139
+ }
131
140
  export function setWindowMetaArgs(name, meta) {
132
141
  const target = windowTarget(name);
133
142
  return [
@@ -61,6 +61,13 @@ export type ClaimResult = {
61
61
  | {
62
62
  kind: 'elsewhere';
63
63
  }
64
+ /**
65
+ * `409 conversation_busy`: an earlier turn of the same employee's conversation is still open.
66
+ * The run stays queued here; the server sends its frame again when that turn ends.
67
+ */
68
+ | {
69
+ kind: 'busy';
70
+ }
64
71
  /** Gone server-side (404 / 410): nothing to run. */
65
72
  | {
66
73
  kind: 'gone';
@@ -78,5 +85,4 @@ export type ClaimResult = {
78
85
  status: number;
79
86
  error?: string;
80
87
  };
81
- /** Claim a run for this computer. Exactly one computer ever gets a 200. */
82
88
  export declare function claimRun(options: ComputerApiOptions, computerId: string, runId: string): Promise<ClaimResult>;
package/dist/host/api.js CHANGED
@@ -94,6 +94,20 @@ function parseSkills(value) {
94
94
  });
95
95
  }
96
96
  /** Claim a run for this computer. Exactly one computer ever gets a 200. */
97
+ /** A refusal's code: `{detail: {error}}`, `{detail: "<code>"}` or `{error}`, matched exactly by the caller. */
98
+ function errorCode(body) {
99
+ if (!body || typeof body !== 'object' || Array.isArray(body))
100
+ return undefined;
101
+ const top = body;
102
+ const detail = top.detail;
103
+ if (typeof detail === 'string')
104
+ return detail;
105
+ if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
106
+ const code = detail.error;
107
+ return typeof code === 'string' ? code : undefined;
108
+ }
109
+ return typeof top.error === 'string' ? top.error : undefined;
110
+ }
97
111
  export async function claimRun(options, computerId, runId) {
98
112
  const response = await callJson(options, claimPath(computerId, runId), {});
99
113
  if (response.status === 200 || response.status === 201) {
@@ -112,7 +126,7 @@ export async function claimRun(options, computerId, runId) {
112
126
  };
113
127
  }
114
128
  if (response.status === 409)
115
- return { kind: 'elsewhere' };
129
+ return errorCode(response.body) === 'conversation_busy' ? { kind: 'busy' } : { kind: 'elsewhere' };
116
130
  if (response.status === 404 || response.status === 410)
117
131
  return { kind: 'gone', status: response.status };
118
132
  if (response.status === 0 || response.status === 429 || response.status >= 500) {