@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.
@@ -9,7 +9,7 @@
9
9
  * up or about to be drawn, and an Enter would answer it. The mark is a file,
10
10
  * so it outlives a daemon restart.
11
11
  */
12
- import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
12
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, rmSync, statSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { codeHostDir, writePrivateJson } from './settings.js';
15
15
  import { isValidId } from './tmux.js';
@@ -35,6 +35,21 @@ export function markReady(terminalId, env = process.env) {
35
35
  return false;
36
36
  }
37
37
  }
38
+ /**
39
+ * Forget the mark: the window's Claude Code was replaced (a restart), and the new one is past its
40
+ * dialog only once its own first hook runs. Returns whether no mark is left.
41
+ */
42
+ export function clearReady(terminalId, env = process.env) {
43
+ if (!isValidId(terminalId))
44
+ return false;
45
+ try {
46
+ rmSync(readyPath(terminalId, env), { force: true });
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
38
53
  /** How much of a transcript is read looking for its folder (its first entries carry `cwd`). */
39
54
  const TRANSCRIPT_HEAD_BYTES = 256 * 1024;
40
55
  /**
@@ -48,22 +63,39 @@ const TRANSCRIPT_HEAD_BYTES = 256 * 1024;
48
63
  * owns, that this window's Claude Code got past its dialog. Used only for a window an older build
49
64
  * started, which never had a ready mark.
50
65
  */
51
- export function claudeSessionSince(cwd, configDir, sinceMs) {
52
- let real;
66
+ /** Claude Code's transcripts dir for `cwd` under `configDir` (`projects/<dash-encoded real path>`), or `undefined`. */
67
+ function projectDir(cwd, configDir) {
53
68
  try {
54
- real = realpathSync(cwd);
69
+ const real = realpathSync(cwd);
70
+ return { real, dir: path.join(configDir, 'projects', real.replace(/[^a-zA-Z0-9]/g, '-')) };
55
71
  }
56
72
  catch {
57
- return false;
73
+ return undefined;
58
74
  }
59
- const dir = path.join(configDir, 'projects', real.replace(/[^a-zA-Z0-9]/g, '-'));
60
- let names;
75
+ }
76
+ /**
77
+ * The transcript files (`<session>.jsonl`) Claude Code keeps for `cwd` under `configDir`: one exists
78
+ * for a session only once its first prompt ran. Empty when there are none or they can't be read.
79
+ */
80
+ export function claudeTranscripts(cwd, configDir) {
81
+ const project = projectDir(cwd, configDir);
82
+ if (!project)
83
+ return [];
61
84
  try {
62
- names = readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
85
+ return readdirSync(project.dir).filter((name) => name.endsWith('.jsonl'));
63
86
  }
64
87
  catch {
65
- return false;
88
+ return [];
66
89
  }
90
+ }
91
+ export function claudeSessionSince(cwd, configDir, sinceMs) {
92
+ const project = projectDir(cwd, configDir);
93
+ if (!project)
94
+ return false;
95
+ const { real, dir } = project;
96
+ const names = claudeTranscripts(cwd, configDir);
97
+ if (names.length === 0)
98
+ return false;
67
99
  return names.some((name) => {
68
100
  const file = path.join(dir, name);
69
101
  let fd;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which agent session a terminal's window runs, and on which login: Claude Code's `session_id`
3
+ * (every hook's input) or Codex's `thread-id` (its `notify` payload), with the window's
4
+ * `CLAUDE_CONFIG_DIR` / `CODEX_HOME` (`null`: the agent's default login), written by the window's own
5
+ * hooks to `<state>/<terminal>.session`. A restart (`CodeTerminals.restart`) resumes exactly that
6
+ * session on that login: a folder thread runs in place in a folder of the owner's, where other
7
+ * sessions of the same agent may be newer, so "the folder's latest" is not this terminal's.
8
+ */
9
+ /** What a recorded id may be: a UUID or a similar token, never anything a shell would read. */
10
+ export declare const SESSION_ID_PATTERN: RegExp;
11
+ export interface RecordedSession {
12
+ id: string;
13
+ /** The login's config dir the session lives under; `null` for the agent's default. */
14
+ login: string | null;
15
+ }
16
+ export declare function sessionPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
17
+ /** The login dir an agent's window runs on, from its environment (`null`: the default login). */
18
+ export declare function loginOf(agent: string, env: NodeJS.ProcessEnv): string | null;
19
+ /** The session the terminal's agent last reported, or `undefined`. */
20
+ export declare function readSession(terminalId: string, env?: NodeJS.ProcessEnv): RecordedSession | undefined;
21
+ /**
22
+ * Record `sessionId` for the terminal, on the login its hook's environment names (best effort: a hook
23
+ * never fails for it). `env` is the hook's own environment, which its window gave it.
24
+ */
25
+ export declare function recordSession(terminalId: string | undefined, agent: string, sessionId: unknown, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Which agent session a terminal's window runs, and on which login: Claude Code's `session_id`
3
+ * (every hook's input) or Codex's `thread-id` (its `notify` payload), with the window's
4
+ * `CLAUDE_CONFIG_DIR` / `CODEX_HOME` (`null`: the agent's default login), written by the window's own
5
+ * hooks to `<state>/<terminal>.session`. A restart (`CodeTerminals.restart`) resumes exactly that
6
+ * session on that login: a folder thread runs in place in a folder of the owner's, where other
7
+ * sessions of the same agent may be newer, so "the folder's latest" is not this terminal's.
8
+ */
9
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { codeHostDir, ensurePrivateDir } from './settings.js';
12
+ import { isValidId } from './tmux.js';
13
+ /** What a recorded id may be: a UUID or a similar token, never anything a shell would read. */
14
+ export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
15
+ export function sessionPath(terminalId, env = process.env) {
16
+ if (!isValidId(terminalId))
17
+ throw new Error('invalid terminal id');
18
+ return path.join(codeHostDir(env), 'state', `${terminalId}.session`);
19
+ }
20
+ /** The login dir an agent's window runs on, from its environment (`null`: the default login). */
21
+ export function loginOf(agent, env) {
22
+ const value = agent === 'codex' ? env.CODEX_HOME : env.CLAUDE_CONFIG_DIR;
23
+ return value?.trim() ? path.resolve(value.trim()) : null;
24
+ }
25
+ /** The session the terminal's agent last reported, or `undefined`. */
26
+ export function readSession(terminalId, env = process.env) {
27
+ if (!isValidId(terminalId))
28
+ return undefined;
29
+ try {
30
+ const raw = JSON.parse(readFileSync(sessionPath(terminalId, env), 'utf8'));
31
+ if (typeof raw.id !== 'string' || !SESSION_ID_PATTERN.test(raw.id))
32
+ return undefined;
33
+ return { id: raw.id, login: typeof raw.login === 'string' && raw.login ? raw.login : null };
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Record `sessionId` for the terminal, on the login its hook's environment names (best effort: a hook
41
+ * never fails for it). `env` is the hook's own environment, which its window gave it.
42
+ */
43
+ export function recordSession(terminalId, agent, sessionId, env = process.env) {
44
+ if (!isValidId(terminalId) || typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId))
45
+ return;
46
+ const next = { id: sessionId, login: loginOf(agent, env) };
47
+ try {
48
+ const current = readSession(terminalId, env);
49
+ if (current?.id === next.id && current.login === next.login)
50
+ return;
51
+ const file = sessionPath(terminalId, env);
52
+ ensurePrivateDir(path.dirname(file));
53
+ // A temp file and a rename: a restart never reads half a record.
54
+ const temp = `${file}.${process.pid}.tmp`;
55
+ writeFileSync(temp, JSON.stringify(next), { mode: 0o600 });
56
+ renameSync(temp, file);
57
+ }
58
+ catch {
59
+ /* the restart then says it can't tell which session */
60
+ }
61
+ }
@@ -33,6 +33,8 @@ export type CodeAgent = (typeof CODE_AGENTS)[number];
33
33
  export declare const GOAL_ENV = "PROHOST_CODE_THREAD_GOAL";
34
34
  /** Window option set when the agent exits and its window becomes a shell. */
35
35
  export declare const EXITED_OPTION = "@prohost_exited";
36
+ /** Window option a relaunched pane stamps with its relaunch's token (`CodeTerminals.restart`). */
37
+ export declare const RELAUNCH_OPTION = "@prohost_relaunch";
36
38
  /**
37
39
  * Window option that marks a relayed reply as possibly sitting in the agent's
38
40
  * input unsubmitted. It is set before the reply's first keystroke reaches the
@@ -101,7 +103,7 @@ export interface TypingOutcome {
101
103
  * `shell_window`: the thread's window is a shell (a shell thread, or an agent that exited into
102
104
  * one), which composer text is never typed into: it would run as a command.
103
105
  */
104
- refused?: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'trust_prompt' | 'shell_window';
106
+ refused?: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'trust_prompt' | 'shell_window' | 'session_unknown' | 'login_changed' | 'login_unavailable' | 'restart_expired';
105
107
  /**
106
108
  * Nothing reached the pane, and the failure was local and may pass (a tmux
107
109
  * command failed): the delivery is worth sending again, not handing back.
@@ -113,6 +115,53 @@ export interface InputRequest {
113
115
  terminalId: string;
114
116
  text: string;
115
117
  }
118
+ /** What a restart that found no conversation to resume says on its thread. */
119
+ export declare function freshConversationNote(agent: CodeAgent): string;
120
+ /** What a restart's caller ties to it (`CodeHost.handleRestart`: its delivery ledger). */
121
+ export interface RestartHooks {
122
+ /**
123
+ * Called right before the relaunch, past every refusal; `false`: an earlier attempt has this one;
124
+ * `'unsaved'`: the claim could not be recorded, so nothing is touched and it is retried later.
125
+ */
126
+ claim?: () => boolean | 'unsaved';
127
+ /** Called once the window is seen running the new process. */
128
+ confirmed?: () => void;
129
+ }
130
+ /** What a restart says back beyond {@link TypingOutcome}. */
131
+ export interface RestartOutcome extends Omit<TypingOutcome, 'refused'> {
132
+ refused?: NonNullable<TypingOutcome['refused']> | 'restart_unconfirmed' | 'agent_exited';
133
+ /** Its delivery was claimed by an earlier attempt: answered as that one was. */
134
+ repeat?: boolean;
135
+ /** Claimed, then nothing was replaced (a clean tmux failure): the claim goes, so it runs when sent again. */
136
+ release?: boolean;
137
+ }
138
+ /** Whether a pane's current command is a shell (a login shell's `-zsh` too). */
139
+ export declare function isShellCommand(command: string): boolean;
140
+ /**
141
+ * How long a relaunched agent is watched for quitting at once before its restart is confirmed: a
142
+ * missing binary or a failed resume exits within a second or two.
143
+ */
144
+ export declare const RESTART_SETTLE_POLLS = 6;
145
+ /** How long a relaunched pane may take to stamp its token (wall clock, from the relaunch). */
146
+ export declare const RESTART_STAMP_WAIT_MS = 10000;
147
+ export declare const RESTART_SETTLE_POLL_MS = 500;
148
+ /** How many times a restart's `stop` is posted, and the first pause between tries (doubling). */
149
+ export declare const RESTART_STOP_ATTEMPTS = 3;
150
+ export declare const RESTART_STOP_BACKOFF_MS = 500;
151
+ /** Then, in the background, this many more rounds, the n-th after n × the pause. */
152
+ export declare const RESTART_STOP_LATER_ATTEMPTS = 4;
153
+ export declare const RESTART_STOP_LATER_PAUSE_MS = 15000;
154
+ /** How old a restart may be when it reaches this Mac (the server's `RESTART_MAX_AGE`). */
155
+ export declare const RESTART_MAX_AGE_MS: number;
156
+ /** `code_thread.restart_requested`: relaunch the thread's agent, continuing its conversation. */
157
+ export interface RestartRequest {
158
+ codeThreadId: string;
159
+ terminalId: string;
160
+ /** When the owner asked (server clock): refused past {@link RESTART_MAX_AGE_MS}. Absent: never stale. */
161
+ requestedAtMs?: number;
162
+ /** Relaunched without its permission prompts (only an explicit `true`; see {@link agentCommandLine}). */
163
+ skipPermissions: boolean;
164
+ }
116
165
  export interface ApprovalRequest {
117
166
  codeThreadId: string;
118
167
  terminalId: string;
@@ -190,6 +239,13 @@ export declare const CLAUDE_SKIP_PERMISSIONS_FLAG = "--dangerously-skip-permissi
190
239
  * `-a never` alone would keep the sandbox, where a command needing the network fails instead of asking.
191
240
  */
192
241
  export declare const CODEX_SKIP_PERMISSIONS_FLAG = "--dangerously-bypass-approvals-and-sandbox";
242
+ /** Claude Code's (`claude --help`, 2.1.318): "Continue the most recent conversation in the current directory". */
243
+ export declare const CLAUDE_CONTINUE_FLAG = "--continue";
244
+ /**
245
+ * Codex's (`codex resume --help`, 0.146): the most recent session without the picker, filtered to
246
+ * the current folder (`--all` would lift that). Its own options (`-c`, the skip flag) follow it.
247
+ */
248
+ export declare const CODEX_RESUME_ARGS = "resume --last";
193
249
  /** The shell command line a thread's agent window runs. */
194
250
  export declare function agentCommandLine(agent: CodeAgent, options: {
195
251
  claudeSettings: string;
@@ -205,6 +261,13 @@ export declare function agentCommandLine(agent: CodeAgent, options: {
205
261
  hasGoal: boolean;
206
262
  /** Start the agent without its permission prompts (a shell has none). */
207
263
  skipPermissions?: boolean;
264
+ /**
265
+ * Continue a conversation instead of starting one (a restart: {@link CodeTerminals.restart}):
266
+ * a session id resumes exactly it (Claude Code `--resume <id>`, Codex `resume <id>`); `'latest'`
267
+ * the window folder's most recent one (`--continue`, `resume --last`), only for a folder that
268
+ * is the thread's own. Never with a goal.
269
+ */
270
+ resume?: string;
208
271
  }): string;
209
272
  export interface CodeTerminalsOptions {
210
273
  /** `$PROHOST_HOME` for this host; exported to every window. */
@@ -296,6 +359,10 @@ export declare class CodeTerminals {
296
359
  /** Per terminal: the tail of its typing queue (see {@link serialize}). */
297
360
  private readonly typing;
298
361
  private openPtys;
362
+ /** The last `adopt` look timed out (`TmuxResult.uncertain`): records were kept, not re-read. */
363
+ private lastListUncertain;
364
+ /** Each agent window's pane process at the last `adopt`: a restart is confirmed by its changing. */
365
+ private panePids;
299
366
  /** Who sizes each window, and what its viewers are told (`sizing.ts`). */
300
367
  readonly sizing: TerminalSizing;
301
368
  private readonly log;
@@ -378,6 +445,57 @@ export declare class CodeTerminals {
378
445
  * reply types: the ready mark does.
379
446
  */
380
447
  private watchTrustPrompt;
448
+ /**
449
+ * Relaunch a thread's Claude Code or Codex in its own window and folder, continuing its
450
+ * conversation (`code_thread.restart_requested`): the process in the agent window is replaced
451
+ * (`respawn-pane -k`), so the window, its viewers and its size stay. Run with
452
+ * {@link RestartRequest.skipPermissions}, it no longer asks before running commands, as a start
453
+ * that skips prompts (the same flags and `--settings` documents). Whatever the old one was doing
454
+ * stops: the person asked for that, from the thread they own.
455
+ *
456
+ * It resumes the session the window's hooks recorded (`session.ts`); a worktree of the thread's
457
+ * own may fall back to its latest session.
458
+ *
459
+ * Refused (nothing is touched, the server says so on the thread) with remote control off
460
+ * (`remote_terminal_disabled`: as for a start, nothing from ProhostAI starts an agent that runs
461
+ * commands unasked then), for a thread whose window is gone (`terminal_gone`), for a shell
462
+ * thread (`shell_window`), or while its session isn't known in a folder it shares
463
+ * (`session_unknown`). An agent that exited into its shell is relaunched all the same.
464
+ * Serialized with the thread's replies, so it never lands between a paste and its Enter.
465
+ */
466
+ restart(request: RestartRequest, outcome?: RestartOutcome, hooks?: RestartHooks): Promise<boolean>;
467
+ /** Once every typing job and restart already queued for the terminal has finished (never rejects). */
468
+ settled(terminalId: string): Promise<void>;
469
+ /** The agent window's pane process as tmux says now; `undefined` when it didn't answer or has none. */
470
+ private panePidNow;
471
+ /** Whether the thread's folder is a worktree this host made for threads (never one of the owner's own). */
472
+ private ownsFolder;
473
+ private restartNow;
474
+ /**
475
+ * Whether a just-relaunched agent quits within a moment. First waits (until {@link RESTART_STAMP_WAIT_MS}
476
+ * have passed) for the window to carry this relaunch's `token` ({@link RELAUNCH_OPTION}, stamped by the new
477
+ * process's own pane after it lifted the old exited mark); only then is {@link EXITED_OPTION} read as
478
+ * 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}`)
479
+ * agree; `running` only when the token was seen and the last look was answered without the mark;
480
+ * anything else (never seen, a look unanswered, the signals disagreeing) is `unclear`.
481
+ */
482
+ private exitsAtOnce;
483
+ /**
484
+ * The restart's `stop` (the person's turn), tried {@link RESTART_STOP_ATTEMPTS} times with a
485
+ * growing pause: whether ProhostAI took it (2xx). A restart is acknowledged done only after that.
486
+ */
487
+ private postRestartStop;
488
+ /** The same `stop`, sent again a few times over a few minutes, in the background; never throws. */
489
+ private postRestartStopLater;
490
+ /**
491
+ * What a restart resumes (`agentCommandLine`'s `resume`): the recorded session when its
492
+ * conversation exists; `'latest'` in a worktree of the thread's own; `undefined` (a fresh start)
493
+ * when there is no conversation yet; `null` when it can't be told (a folder of the owner's, its
494
+ * session never reported). Claude Code writes a session's transcript only once its first prompt
495
+ * runs (`ready.ts`), so a session id from `SessionStart` alone has nothing to resume. Codex reports
496
+ * its id only after a turn (`notify`), so a recorded one has.
497
+ */
498
+ private resumeTarget;
381
499
  private known;
382
500
  /**
383
501
  * Whether the thread's agent window runs an agent that composer text and