@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.
- package/CHANGELOG.md +53 -0
- package/README.md +21 -4
- package/dist/agent/daemon.js +12 -1
- package/dist/agent/run.js +12 -4
- package/dist/codehost/hooks.d.ts +2 -0
- package/dist/codehost/hooks.js +15 -1
- package/dist/codehost/host.d.ts +24 -5
- package/dist/codehost/host.js +85 -3
- package/dist/codehost/ledger.d.ts +16 -2
- package/dist/codehost/ledger.js +43 -6
- package/dist/codehost/ready.d.ts +8 -9
- package/dist/codehost/ready.js +41 -9
- package/dist/codehost/session.d.ts +25 -0
- package/dist/codehost/session.js +61 -0
- package/dist/codehost/terminals.d.ts +119 -1
- package/dist/codehost/terminals.js +369 -5
- package/dist/codehost/tmux.d.ts +6 -0
- package/dist/codehost/tmux.js +9 -0
- package/dist/host/api.d.ts +7 -1
- package/dist/host/api.js +15 -1
- package/dist/host/command.js +334 -9
- package/dist/host/computer.d.ts +31 -0
- package/dist/host/computer.js +36 -0
- package/dist/host/install.d.ts +64 -0
- package/dist/host/install.js +317 -7
- package/dist/host/migrate.js +5 -1
- package/dist/host/pair.d.ts +1 -1
- package/dist/host/pair.js +2 -0
- package/dist/host/scheduler.d.ts +5 -1
- package/dist/host/scheduler.js +7 -1
- package/dist/host/service.d.ts +5 -0
- package/dist/host/service.js +49 -7
- package/dist/index.js +5 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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;
|
package/dist/codehost/tmux.d.ts
CHANGED
|
@@ -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;
|
package/dist/codehost/tmux.js
CHANGED
|
@@ -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 [
|
package/dist/host/api.d.ts
CHANGED
|
@@ -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) {
|