@prohost/cli 0.13.3 → 0.15.0
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 +90 -0
- package/README.md +129 -0
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +230 -9
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +186 -0
- package/dist/codehost/hooks.js +737 -0
- package/dist/codehost/host.d.ts +297 -0
- package/dist/codehost/host.js +635 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +175 -0
- package/dist/codehost/relay.js +598 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +181 -0
- package/dist/codehost/sizing.js +434 -0
- package/dist/codehost/terminals.d.ts +421 -0
- package/dist/codehost/terminals.js +1027 -0
- package/dist/codehost/tmux.d.ts +201 -0
- package/dist/codehost/tmux.js +394 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Mac's Code-thread terminals: one tmux window per thread, attached
|
|
3
|
+
* through a pty per viewer.
|
|
4
|
+
*
|
|
5
|
+
* - {@link CodeTerminals.start} handles `code_thread.start_requested`: a
|
|
6
|
+
* worktree for the thread, then a window running the agent (`claude`,
|
|
7
|
+
* `codex` or a shell) with `PROHOST_TERMINAL_ID` / `PROHOST_CODE_THREAD_ID`
|
|
8
|
+
* in its environment — what makes the hooks fire for it and for nothing
|
|
9
|
+
* else. When the agent exits, the window becomes a shell in the worktree; it
|
|
10
|
+
* never just disappears under a viewer.
|
|
11
|
+
* - {@link CodeTerminals.input} handles `code_thread.input_requested`: the text
|
|
12
|
+
* typed into a composer that isn't attached to the terminal, sent to the
|
|
13
|
+
* pane as one paste plus Enter.
|
|
14
|
+
* - {@link CodeTerminals.attach} gives a viewer (relay or desktop) a byte
|
|
15
|
+
* stream to one of a thread's windows.
|
|
16
|
+
* - {@link CodeTerminals.adopt} finds the threads a previous daemon started:
|
|
17
|
+
* tmux owns their processes, so a daemon restart costs nothing.
|
|
18
|
+
*
|
|
19
|
+
* Every start and input is idempotent under redelivery: a window that exists
|
|
20
|
+
* is the thread already started.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { closeSync, openSync, utimesSync } from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { shellQuote } from '../agent/shell.js';
|
|
27
|
+
import { worktreesRoot } from '../agent/worktrees.js';
|
|
28
|
+
import { markTurn } from './activity.js';
|
|
29
|
+
import { missingAgentReason } from './agents.js';
|
|
30
|
+
import { claudeConfigDir, claudeTrusts, trustClaudeWorkspace } from './claude_trust.js';
|
|
31
|
+
import { event } from './events.js';
|
|
32
|
+
import { readHistory } from './history.js';
|
|
33
|
+
import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
|
|
34
|
+
import { MAX_OPEN_PTYS } from './pty.js';
|
|
35
|
+
import { claudeSessionSince, isReady, markReady } from './ready.js';
|
|
36
|
+
import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
|
|
37
|
+
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';
|
|
39
|
+
export const CODE_AGENTS = ['claude_code', 'codex', 'shell'];
|
|
40
|
+
export const GOAL_ENV = 'PROHOST_CODE_THREAD_GOAL';
|
|
41
|
+
/** Window option set when the agent exits and its window becomes a shell. */
|
|
42
|
+
export const EXITED_OPTION = '@prohost_exited';
|
|
43
|
+
/**
|
|
44
|
+
* Window option that marks a relayed reply as possibly sitting in the agent's
|
|
45
|
+
* input unsubmitted. It is set before the reply's first keystroke reaches the
|
|
46
|
+
* pane and cleared only once its Enter went through, so a reply stopped half
|
|
47
|
+
* way — a revocation or `remote off` between the steps, a tmux error, the
|
|
48
|
+
* daemon dying — leaves it set. Relayed text is refused while it is set, so
|
|
49
|
+
* the next reply can't append to the leftover and submit both; the owner
|
|
50
|
+
* typing in a terminal attached to that window (where they see the input box)
|
|
51
|
+
* clears it.
|
|
52
|
+
*/
|
|
53
|
+
export const STAGED_OPTION = '@prohost_staged';
|
|
54
|
+
/** Output held for a viewer whose socket isn't connected yet (tmux's first redraw). */
|
|
55
|
+
const MAX_EARLY_OUTPUT_BYTES = 1024 * 1024;
|
|
56
|
+
const MAX_GOAL_CHARS = 32_000;
|
|
57
|
+
const MAX_INPUT_CHARS = 32_000;
|
|
58
|
+
/** One Esc or Ctrl+C burst reports one interrupt. */
|
|
59
|
+
const INTERRUPT_DEBOUNCE_MS = 2_000;
|
|
60
|
+
/** Between a paste and its Enter, so the agent has taken the paste in. */
|
|
61
|
+
const PASTE_SETTLE_MS = 150;
|
|
62
|
+
export function execTmux(binary = 'tmux') {
|
|
63
|
+
return (args, stdin, timeoutMs = 10_000) => new Promise((resolve) => {
|
|
64
|
+
// The first call starts the server, whose panes inherit this env.
|
|
65
|
+
// Room for a viewer's scroll-back (`capture-pane`, up to 10,000 lines with their colours).
|
|
66
|
+
const options = { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: paneEnv(process.env) };
|
|
67
|
+
const child = execFile(binary, args, options, (error, stdout, stderr) => {
|
|
68
|
+
resolve({
|
|
69
|
+
ok: error === null,
|
|
70
|
+
stdout: String(stdout),
|
|
71
|
+
stderr: String(stderr) || (error?.message ?? ''),
|
|
72
|
+
...(error && (error.killed || error.signal) ? { uncertain: true } : {}),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
if (stdin !== undefined) {
|
|
76
|
+
child.stdin?.end(stdin);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether an event post's status settles it (see {@link StartResult}): the
|
|
82
|
+
* server applied it (2xx) or will refuse it the same way every time (404, 409,
|
|
83
|
+
* 422…). A 401 / 403 is the token — expired, or minted before a reconnect —
|
|
84
|
+
* and the next control-socket connect brings a fresh one, so like no answer
|
|
85
|
+
* at all (0, 408, 429, 5xx) it is retried.
|
|
86
|
+
*/
|
|
87
|
+
export function isDefinitive(status) {
|
|
88
|
+
if (status >= 200 && status < 300)
|
|
89
|
+
return true;
|
|
90
|
+
return status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
|
|
91
|
+
}
|
|
92
|
+
/** Claude Code's flag for a thread started with `skipPermissions`: no permission prompt, ever. */
|
|
93
|
+
export const CLAUDE_SKIP_PERMISSIONS_FLAG = '--dangerously-skip-permissions';
|
|
94
|
+
/**
|
|
95
|
+
* Codex's (`codex --help`, 0.146): no approval prompt and no sandbox, as Claude Code's flag. Its
|
|
96
|
+
* `-a never` alone would keep the sandbox, where a command needing the network fails instead of asking.
|
|
97
|
+
*/
|
|
98
|
+
export const CODEX_SKIP_PERMISSIONS_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
|
99
|
+
/** The shell command line a thread's agent window runs. */
|
|
100
|
+
export function agentCommandLine(agent, options) {
|
|
101
|
+
// `--` first: a goal is the agent's prompt, never an option, however it
|
|
102
|
+
// starts (`--dangerously-skip-permissions`, or a word Codex would read as a
|
|
103
|
+
// subcommand).
|
|
104
|
+
const goal = options.hasGoal ? ` -- "$${GOAL_ENV}"` : '';
|
|
105
|
+
const skip = options.skipPermissions === true;
|
|
106
|
+
const shell = 'exec "${SHELL:-/bin/zsh}" -l';
|
|
107
|
+
if (agent === 'shell')
|
|
108
|
+
return shell;
|
|
109
|
+
const label = agent === 'codex' ? 'Codex' : 'Claude Code';
|
|
110
|
+
// Marks the window first: from here on it is a plain shell, and a reply
|
|
111
|
+
// relayed from ProhostAI must never be typed into it as a command
|
|
112
|
+
// (`input` and `approve` refuse a window carrying the mark).
|
|
113
|
+
const after = `tmux set-option -w ${EXITED_OPTION} 1 2>/dev/null; ` +
|
|
114
|
+
`printf '\\n[${label} exited — this window is now a shell in the thread worktree]\\n'; ${shell}`;
|
|
115
|
+
if (agent === 'codex') {
|
|
116
|
+
const flags = skip ? ` ${CODEX_SKIP_PERMISSIONS_FLAG}` : '';
|
|
117
|
+
return `codex${flags} -c ${shellQuote(options.codexNotify, 'darwin')}${goal}; ${after}`;
|
|
118
|
+
}
|
|
119
|
+
const claude = `command claude${skip ? ` ${CLAUDE_SKIP_PERMISSIONS_FLAG}` : ''} --settings`;
|
|
120
|
+
// A window that skips prompts reads the documents that also accept Claude Code's bypass warning.
|
|
121
|
+
const settings = (skip && options.claudeSkipSettings) || options.claudeSettings;
|
|
122
|
+
const invocationSettings = (skip && options.claudeInvocationSkipSettings) || options.claudeInvocationSettings;
|
|
123
|
+
if (invocationSettings && options.hookShim) {
|
|
124
|
+
const check = `${shellQuote(options.hookShim, 'darwin')} claude CheckInvocationProtocol`;
|
|
125
|
+
return `if ${check}; then PROHOST_INVOCATION_PROTOCOL=1 ${claude} ${shellQuote(invocationSettings, 'darwin')}${goal}; ` +
|
|
126
|
+
`else PROHOST_INVOCATION_PROTOCOL=0 ${claude} ${shellQuote(settings, 'darwin')}${goal}; fi; ${after}`;
|
|
127
|
+
}
|
|
128
|
+
return `PROHOST_INVOCATION_PROTOCOL=0 ${claude} ${shellQuote(settings, 'darwin')}${goal}; ${after}`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A phrase as a first-run screen shows it: Claude Code wraps its own text inside a bordered box (a
|
|
132
|
+
* narrow window breaks it anywhere, and `capture-pane -J` joins only tmux's wraps), so words may be
|
|
133
|
+
* apart by any whitespace or the box's border.
|
|
134
|
+
*/
|
|
135
|
+
function screenPhrase(...phrases) {
|
|
136
|
+
const gap = String.raw `[\s│┃|]+`;
|
|
137
|
+
const escape = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
|
+
return new RegExp(phrases.map((phrase) => phrase.split(' ').map(escape).join(gap)).join('|'), 'i');
|
|
139
|
+
}
|
|
140
|
+
/** Claude Code's workspace trust dialog, as its first screen shows it. */
|
|
141
|
+
const TRUST_SCREEN = screenPhrase('Is this a project you created or one you trust', 'Do you trust the files in this folder');
|
|
142
|
+
/** How many times after launch the host looks for that dialog, and how far apart. */
|
|
143
|
+
const TRUST_WATCH_ATTEMPTS = 30;
|
|
144
|
+
/**
|
|
145
|
+
* A thread that skips prompts, once the trust dialog was said: how long (polls) it still looks for
|
|
146
|
+
* the bypass warning, which comes up only after that dialog is answered, however long that takes.
|
|
147
|
+
*/
|
|
148
|
+
const BYPASS_WATCH_ATTEMPTS = 600;
|
|
149
|
+
const TRUST_POLL_MS = 1_000;
|
|
150
|
+
/** What the thread says while Claude Code waits on its trust dialog. */
|
|
151
|
+
export const TRUST_PROMPT_NOTE = 'Claude Code is asking whether to trust this folder before it starts. Answer in the terminal.';
|
|
152
|
+
/**
|
|
153
|
+
* Claude Code's one-time warning before its first `--dangerously-skip-permissions` run on a login
|
|
154
|
+
* that never accepted it. A skipping window's `--settings` document accepts it (`hooks.ts`,
|
|
155
|
+
* `skipDangerousModePermissionPrompt`); this is the fallback for a Claude Code that shows it anyway:
|
|
156
|
+
* the thread says it waits on the person.
|
|
157
|
+
*/
|
|
158
|
+
const BYPASS_SCREEN = screenPhrase('In Bypass Permissions mode, Claude Code will not ask');
|
|
159
|
+
/** What the thread says while Claude Code waits on that warning. */
|
|
160
|
+
export const BYPASS_PROMPT_NOTE = 'Claude Code asks once to confirm it may run commands without asking. Answer in the terminal.';
|
|
161
|
+
/**
|
|
162
|
+
* The environment a Code thread's Claude Code starts with, beyond its account's:
|
|
163
|
+
* no Claude in Chrome. A thread runs where nobody may be watching, and the
|
|
164
|
+
* "use your browser?" offer on first run would hold the goal back unseen.
|
|
165
|
+
*/
|
|
166
|
+
const CLAUDE_THREAD_ENV = { CLAUDE_CODE_ENABLE_CFC: '0' };
|
|
167
|
+
/**
|
|
168
|
+
* The agent's own part of a thread window's environment: its account's login (`CLAUDE_CONFIG_DIR`
|
|
169
|
+
* and the like), then the thread's overrides, which a login's environment never undoes. The one
|
|
170
|
+
* builder for it: `startOnce` launches with it, and `adoptReadiness` reads the config dir from it.
|
|
171
|
+
*/
|
|
172
|
+
function agentWindowEnv(agent, accountEnv) {
|
|
173
|
+
return { ...stringEnv(accountEnv), ...(agent === 'claude_code' ? CLAUDE_THREAD_ENV : {}) };
|
|
174
|
+
}
|
|
175
|
+
export class CodeTerminals {
|
|
176
|
+
options;
|
|
177
|
+
records = new Map();
|
|
178
|
+
starting = new Map();
|
|
179
|
+
/**
|
|
180
|
+
* Older windows are adopted past trust by one pass that succeeds once per daemon start
|
|
181
|
+
* (`adoptReadiness`); a failed pass is forgotten and tried again by the next `adopt()`, or by a
|
|
182
|
+
* reply it would have let through. The pass in flight or done, so every `adopt()` (a concurrent
|
|
183
|
+
* one too) returns only once it has finished.
|
|
184
|
+
*/
|
|
185
|
+
readinessAdoption;
|
|
186
|
+
lastInterrupt = new Map();
|
|
187
|
+
/** Per terminal: the tail of its typing queue (see {@link serialize}). */
|
|
188
|
+
typing = new Map();
|
|
189
|
+
openPtys = 0;
|
|
190
|
+
/** Who sizes each window, and what its viewers are told (`sizing.ts`). */
|
|
191
|
+
sizing;
|
|
192
|
+
log;
|
|
193
|
+
now;
|
|
194
|
+
sleep;
|
|
195
|
+
constructor(options) {
|
|
196
|
+
this.options = options;
|
|
197
|
+
this.log = options.log ?? (() => { });
|
|
198
|
+
this.now = options.now ?? Date.now;
|
|
199
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
200
|
+
this.sizing = new TerminalSizing({
|
|
201
|
+
tmux: (args) => this.tmux(args),
|
|
202
|
+
setTimer: options.setTimer,
|
|
203
|
+
now: this.now,
|
|
204
|
+
defaultSize: { cols: DEFAULT_COLS, rows: DEFAULT_ROWS },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
isReady(terminalId) {
|
|
208
|
+
return this.options.ready ? this.options.ready(terminalId) : isReady(terminalId, { PROHOST_HOME: this.options.home });
|
|
209
|
+
}
|
|
210
|
+
isAuthorized() {
|
|
211
|
+
return this.options.authorized?.() ?? true;
|
|
212
|
+
}
|
|
213
|
+
canType() {
|
|
214
|
+
return this.isAuthorized() && (this.options.remoteControlAllowed?.() ?? true);
|
|
215
|
+
}
|
|
216
|
+
tmux(args, stdin, timeoutMs) {
|
|
217
|
+
return this.options.tmux([...baseArgs(this.options.socket, this.options.confPath), ...args], stdin, timeoutMs);
|
|
218
|
+
}
|
|
219
|
+
/** Threads this host knows about right now. */
|
|
220
|
+
list() {
|
|
221
|
+
// What a thread is, never how this host tracks its trust (`readyMark`, its Claude config dir).
|
|
222
|
+
return [...this.records.values()].map(({ terminalId, codeThreadId, agent, cwd, windows }) => ({
|
|
223
|
+
terminalId,
|
|
224
|
+
codeThreadId,
|
|
225
|
+
agent,
|
|
226
|
+
cwd,
|
|
227
|
+
windows: [...windows],
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
/** Ptys open right now. Exposed for assertions. */
|
|
231
|
+
openPtyCount() {
|
|
232
|
+
return this.openPtys;
|
|
233
|
+
}
|
|
234
|
+
/** Re-read the windows tmux holds. Returns how many threads are known after. */
|
|
235
|
+
/**
|
|
236
|
+
* Run `work` after every earlier typing job for this terminal. A paste and
|
|
237
|
+
* its Enter (or an approval's keys) are one unit: two deliveries for one
|
|
238
|
+
* terminal never interleave into "first, second, Enter, Enter".
|
|
239
|
+
*/
|
|
240
|
+
serialize(terminalId, work) {
|
|
241
|
+
const previous = this.typing.get(terminalId) ?? Promise.resolve();
|
|
242
|
+
const next = previous.then(work, work);
|
|
243
|
+
const tail = next.catch(() => undefined);
|
|
244
|
+
this.typing.set(terminalId, tail);
|
|
245
|
+
void tail.then(() => {
|
|
246
|
+
if (this.typing.get(terminalId) === tail)
|
|
247
|
+
this.typing.delete(terminalId);
|
|
248
|
+
});
|
|
249
|
+
return next;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Mark a thread's worktree lane as in use (`.last-used`, the marker every
|
|
253
|
+
* {@link WorktreeManager} prunes by), so a long-lived terminal's checkout is
|
|
254
|
+
* never removed as idle while its window lives — whichever lane's prepare
|
|
255
|
+
* happens to run the prune.
|
|
256
|
+
*/
|
|
257
|
+
touchLane(record) {
|
|
258
|
+
// Only a lane this host made (`worktrees/<lane>/…`): a thread running in place in a folder
|
|
259
|
+
// has none, and nothing is ever written beside a folder of the owner's.
|
|
260
|
+
const root = worktreesRoot({ PROHOST_HOME: this.options.home });
|
|
261
|
+
const relative = path.relative(root, record.cwd);
|
|
262
|
+
const lane = relative.split(path.sep)[0];
|
|
263
|
+
if (!relative || path.isAbsolute(relative) || !lane || lane === '..' || lane === relative)
|
|
264
|
+
return;
|
|
265
|
+
const marker = path.join(root, lane, '.last-used');
|
|
266
|
+
const stamp = new Date(this.now());
|
|
267
|
+
try {
|
|
268
|
+
closeSync(openSync(marker, 'a'));
|
|
269
|
+
utimesSync(marker, stamp, stamp);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* a lane that no longer exists has nothing to keep */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Refresh the lanes of threads whose windows tmux still has (adopt reconciles first). */
|
|
276
|
+
touchLiveLanes() {
|
|
277
|
+
for (const record of this.records.values())
|
|
278
|
+
this.touchLane(record);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The hourly keeper: re-read tmux, then mark only the threads it still holds
|
|
282
|
+
* as in use. A window or server that went away is dropped by the re-read, so
|
|
283
|
+
* its lane is left to age out and be pruned as usual.
|
|
284
|
+
*/
|
|
285
|
+
async refreshLiveLanes() {
|
|
286
|
+
await this.adopt();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Apply the config just written to a server that is already running: tmux
|
|
290
|
+
* reads `-f` only when a server starts, and the persistent session keeps an
|
|
291
|
+
* upgraded host's old server (and its old settings) alive. With no server
|
|
292
|
+
* there is nothing to apply, and the next one starts with the file.
|
|
293
|
+
*/
|
|
294
|
+
async reloadConfig() {
|
|
295
|
+
const reloaded = (await this.tmux(['source-file', this.options.confPath])).ok;
|
|
296
|
+
// A server started before `paneEnv` (by a daemon the desktop app ran on Electron) holds those
|
|
297
|
+
// in its global environment and copies them into every new window: taken out of it too.
|
|
298
|
+
if (reloaded)
|
|
299
|
+
for (const key of DAEMON_ONLY_ENV)
|
|
300
|
+
await this.tmux(['set-environment', '-g', '-u', key]);
|
|
301
|
+
return reloaded;
|
|
302
|
+
}
|
|
303
|
+
async adopt() {
|
|
304
|
+
const listed = await this.tmux(listWindowsArgs());
|
|
305
|
+
if (!listed.ok) {
|
|
306
|
+
// No server or no session: nothing survived, and nothing is wrong.
|
|
307
|
+
this.records.clear();
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
const names = new Set();
|
|
311
|
+
const found = new Map();
|
|
312
|
+
const panes = new Map();
|
|
313
|
+
for (const window of parseListWindows(listed.stdout)) {
|
|
314
|
+
names.add(window.name);
|
|
315
|
+
// Left by an earlier daemon, perhaps still holding its shell: released in time all the same.
|
|
316
|
+
this.sizing.adopted(window.name);
|
|
317
|
+
if (window.meta && window.name === windowName(window.meta.terminalId, 'agent')) {
|
|
318
|
+
found.set(window.meta.terminalId, { ...window.meta, windows: ['agent'] });
|
|
319
|
+
if (window.panePid)
|
|
320
|
+
panes.set(window.meta.terminalId, window.panePid);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const record of found.values()) {
|
|
324
|
+
if (names.has(windowName(record.terminalId, 'shell')))
|
|
325
|
+
record.windows.push('shell');
|
|
326
|
+
}
|
|
327
|
+
this.records.clear();
|
|
328
|
+
for (const [id, record] of found)
|
|
329
|
+
this.records.set(id, record);
|
|
330
|
+
// Once per daemon start: the windows an older build left behind are all there now. Started by
|
|
331
|
+
// the first call; every call waits for it, so none returns before those windows are marked. A
|
|
332
|
+
// pass that fails (a keychain still locked right after login) never fails an adopt: it is
|
|
333
|
+
// logged and forgotten, so the next adopt tries once more, rather than leaving those windows'
|
|
334
|
+
// replies refused until a restart.
|
|
335
|
+
if (!this.readinessAdoption) {
|
|
336
|
+
const attempt = this.adoptReadinessAll([...found.values()], panes).catch((err) => {
|
|
337
|
+
this.log(` ! older code threads not checked for readiness: ${err instanceof Error ? err.message : String(err)}`);
|
|
338
|
+
if (this.readinessAdoption === attempt)
|
|
339
|
+
this.readinessAdoption = undefined;
|
|
340
|
+
});
|
|
341
|
+
this.readinessAdoption = attempt;
|
|
342
|
+
}
|
|
343
|
+
await this.readinessAdoption;
|
|
344
|
+
this.touchLiveLanes();
|
|
345
|
+
return this.records.size;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The environment a Claude Code thread starts with: what `startOnce` hands its window, and so
|
|
349
|
+
* the only source of where that window's Claude Code keeps its sessions (`adoptReadiness`).
|
|
350
|
+
*/
|
|
351
|
+
claudeLaunchEnv() {
|
|
352
|
+
const account = this.options.accountEnv?.('claude_code') ?? { ok: true, env: {} };
|
|
353
|
+
if (!account.ok)
|
|
354
|
+
return account;
|
|
355
|
+
return { ok: true, env: { ...(this.options.processEnv ?? process.env), ...agentWindowEnv('claude_code', account.env) } };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Older windows, all at once: one `ps` for every candidate's start (bounded, never one call per
|
|
359
|
+
* window), then the evidence check for each (`adoptReadiness`).
|
|
360
|
+
*/
|
|
361
|
+
async adoptReadinessAll(records, panes) {
|
|
362
|
+
const candidates = records.filter((record) => record.agent === 'claude_code' && !record.readyMark && this.isReady(record.terminalId) === false);
|
|
363
|
+
const pids = candidates.map((record) => panes.get(record.terminalId)).filter((pid) => pid !== undefined);
|
|
364
|
+
if (pids.length === 0)
|
|
365
|
+
return;
|
|
366
|
+
// That window's Claude Code runs on the config this host launches Claude threads with: the
|
|
367
|
+
// same function builds the launch env, so the dir looked in can't drift from the dir used.
|
|
368
|
+
// Read before `ps`, which a pass that can't go on never needs to run.
|
|
369
|
+
const launch = this.claudeLaunchEnv();
|
|
370
|
+
// A failure, not "nothing to adopt": the pass is tried again (`adopt`), and says why.
|
|
371
|
+
if (!launch.ok)
|
|
372
|
+
throw new Error(`the Claude account's environment isn't available (${launch.reason})`);
|
|
373
|
+
const dir = claudeConfigDir(launch.env);
|
|
374
|
+
const starts = await (this.options.paneStarts ?? paneStarts)(pids);
|
|
375
|
+
for (const record of candidates) {
|
|
376
|
+
const pid = panes.get(record.terminalId);
|
|
377
|
+
const startedAtMs = pid === undefined ? undefined : starts.get(pid);
|
|
378
|
+
if (startedAtMs !== undefined)
|
|
379
|
+
this.adoptReadiness(record, dir, startedAtMs);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* A Claude window from a build before the ready mark (an upgrade): its Claude Code may be long
|
|
384
|
+
* past trust, but no hook runs to say so until a reply types, which the missing mark refuses.
|
|
385
|
+
* Only such a window (no `readyMark` in its meta: this build's windows get the mark from their
|
|
386
|
+
* first hook, never from here), by a pass that succeeds once per daemon start (and is tried
|
|
387
|
+
* again after a failure; each try judges afresh), and only on evidence tied to it: a
|
|
388
|
+
* transcript in that window's Claude config dir (`claudeLaunchEnv`), naming its exact folder,
|
|
389
|
+
* written since the window's process started (`claudeSessionSince`). A window still at its
|
|
390
|
+
* trust dialog has none and stays refused. Never judged from the screen.
|
|
391
|
+
*/
|
|
392
|
+
adoptReadiness(record, configDir, startedAtMs) {
|
|
393
|
+
if (!(this.options.claudeSessionSince ?? claudeSessionSince)(record.cwd, configDir, startedAtMs))
|
|
394
|
+
return;
|
|
395
|
+
if (markReady(record.terminalId, { PROHOST_HOME: this.options.home })) {
|
|
396
|
+
this.log(` ⚙ code thread ${record.codeThreadId.slice(0, 12)}: adopted past Claude Code's trust dialog (its session exists)`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async ensureSession() {
|
|
400
|
+
if ((await this.tmux(hasSessionArgs())).ok)
|
|
401
|
+
return true;
|
|
402
|
+
const created = await this.tmux(newSessionArgs(this.options.home));
|
|
403
|
+
// Another start may have created it a moment ago.
|
|
404
|
+
return created.ok || (await this.tmux(hasSessionArgs())).ok;
|
|
405
|
+
}
|
|
406
|
+
async start(request) {
|
|
407
|
+
if (!isValidId(request.terminalId) || !isValidId(request.codeThreadId)) {
|
|
408
|
+
// Nothing to report to, and a redelivery would be the same: settled.
|
|
409
|
+
return { ok: false, reason: 'invalid terminal or thread id', reported: true };
|
|
410
|
+
}
|
|
411
|
+
const pending = this.starting.get(request.terminalId);
|
|
412
|
+
if (pending)
|
|
413
|
+
return pending;
|
|
414
|
+
const work = this.startOnce(request).finally(() => this.starting.delete(request.terminalId));
|
|
415
|
+
this.starting.set(request.terminalId, work);
|
|
416
|
+
return work;
|
|
417
|
+
}
|
|
418
|
+
async startOnce(request) {
|
|
419
|
+
const reportStart = async () => isDefinitive(await this.options.events.post(request.codeThreadId, event('start', { terminalId: request.terminalId, agent: request.agent, branch: request.branch ?? null }, this.now())));
|
|
420
|
+
await this.adopt();
|
|
421
|
+
// A redelivery (the first outcome never reached the server) reports again.
|
|
422
|
+
if (this.records.has(request.terminalId))
|
|
423
|
+
return { ok: true, adopted: true, reported: await reportStart() };
|
|
424
|
+
const fail = async (reason) => {
|
|
425
|
+
this.log(` ✗ code thread ${request.codeThreadId.slice(0, 12)} could not start: ${reason}`);
|
|
426
|
+
const status = await this.options.events.post(request.codeThreadId, event('failed', { reason }, this.now()));
|
|
427
|
+
return { ok: false, reason, reported: isDefinitive(status) };
|
|
428
|
+
};
|
|
429
|
+
if (!CODE_AGENTS.includes(request.agent))
|
|
430
|
+
return fail(`unknown agent ${JSON.stringify(request.agent)}`);
|
|
431
|
+
// Its binary isn't here: refused with why (the thread shows it), never a window saying
|
|
432
|
+
// `command not found`. A server that read this Mac's `code_thread_agent_*` never asks.
|
|
433
|
+
if (request.agent !== 'shell' && this.options.agentInstalled && !this.options.agentInstalled(request.agent)) {
|
|
434
|
+
return fail(missingAgentReason(request.agent));
|
|
435
|
+
}
|
|
436
|
+
// A raw shell is remote control of this Mac: never opened with the switch off. Reported as a
|
|
437
|
+
// failed start (never acked unhandled), so its thread doesn't wait in `starting` for good.
|
|
438
|
+
// A folder start's shell is the exception: it opens in a folder this Mac's owner opened here,
|
|
439
|
+
// and with the switch off nothing from ProhostAI types into it (replies refuse a shell, the
|
|
440
|
+
// relay refuses every viewer); only the desktop app on this Mac attaches (`local_server.ts`).
|
|
441
|
+
if (request.agent === 'shell' && request.folder === undefined && !this.canType()) {
|
|
442
|
+
return fail('remote terminal access is off on this Mac (prohost agent code-host remote on)');
|
|
443
|
+
}
|
|
444
|
+
// Never a silent fall back to another login: a thread spends the
|
|
445
|
+
// subscription this agent reports, or it doesn't start.
|
|
446
|
+
const account = this.options.accountEnv?.(request.agent) ?? { ok: true, env: {} };
|
|
447
|
+
if (!account.ok)
|
|
448
|
+
return fail(account.reason);
|
|
449
|
+
const checkouts = this.options.checkouts;
|
|
450
|
+
const checkout = request.folder === undefined
|
|
451
|
+
? await checkouts.prepare(request.repo, request.terminalId, request.branch)
|
|
452
|
+
: checkouts.prepareFolder
|
|
453
|
+
? await checkouts.prepareFolder(request.folder, request.terminalId, {
|
|
454
|
+
separateCopy: request.separateCopy === true,
|
|
455
|
+
branch: request.branch,
|
|
456
|
+
})
|
|
457
|
+
: { ok: false, reason: 'This Mac can’t start Code threads in a folder.' };
|
|
458
|
+
if (!checkout.ok)
|
|
459
|
+
return fail(checkout.reason);
|
|
460
|
+
if (!(await this.ensureSession()))
|
|
461
|
+
return fail('tmux is not available on this Mac (brew install tmux)');
|
|
462
|
+
const goal = request.goal.slice(0, MAX_GOAL_CHARS);
|
|
463
|
+
const name = windowName(request.terminalId, 'agent');
|
|
464
|
+
if (!this.isAuthorized())
|
|
465
|
+
return fail('ProhostAI no longer authorizes this pairing');
|
|
466
|
+
if (request.agent === 'claude_code')
|
|
467
|
+
await this.trustOwnWorktree(checkout, account.env);
|
|
468
|
+
// Started at the size it will most likely be viewed at (the last one a viewer sized a window
|
|
469
|
+
// to), not tmux's 80x24: every resize after it makes a shell redraw its prompt, and a TUI its screen.
|
|
470
|
+
const size = this.sizing.lastSize();
|
|
471
|
+
await this.tmux(defaultSizeArgs(size.cols, size.rows));
|
|
472
|
+
// Never with remote control off: then nothing from ProhostAI may run a command here unasked
|
|
473
|
+
// (a goal is the agent's first instruction). The server only asks; the Mac decides.
|
|
474
|
+
const skips = request.skipPermissions === true && request.agent !== 'shell' && this.canType();
|
|
475
|
+
const command = agentCommandLine(request.agent, { ...this.options.hookFiles, hasGoal: goal.length > 0, skipPermissions: skips });
|
|
476
|
+
// A shell thread's shell waits for its first viewer's size to settle (`sizing.ts`): one prompt.
|
|
477
|
+
const held = request.agent === 'shell';
|
|
478
|
+
const created = await this.tmux(newWindowArgs({
|
|
479
|
+
name,
|
|
480
|
+
cwd: checkout.path,
|
|
481
|
+
env: {
|
|
482
|
+
...agentWindowEnv(request.agent, account.env),
|
|
483
|
+
PROHOST_HOME: this.options.home,
|
|
484
|
+
[TERMINAL_ID_ENV]: request.terminalId,
|
|
485
|
+
[CODE_THREAD_ID_ENV]: request.codeThreadId,
|
|
486
|
+
...(goal ? { [GOAL_ENV]: goal } : {}),
|
|
487
|
+
},
|
|
488
|
+
command: held ? holdCommand(name, command) : command,
|
|
489
|
+
}));
|
|
490
|
+
if (!created.ok)
|
|
491
|
+
return fail(`tmux could not open the thread's window (${created.stderr.trim()})`);
|
|
492
|
+
// At that size whatever is attached (tmux sizes a new window from the viewer active last), and
|
|
493
|
+
// manual from now on: resize-window sets `window-size manual` on the window.
|
|
494
|
+
await this.tmux(resizeWindowArgs(name, size.cols, size.rows));
|
|
495
|
+
this.sizing.created(name, { held: held && (await this.markHeld(name)) });
|
|
496
|
+
const meta = {
|
|
497
|
+
terminalId: request.terminalId,
|
|
498
|
+
codeThreadId: request.codeThreadId,
|
|
499
|
+
agent: request.agent,
|
|
500
|
+
cwd: checkout.path,
|
|
501
|
+
// This build tracks trust with the ready mark: this window is never adopted past trust.
|
|
502
|
+
readyMark: true,
|
|
503
|
+
};
|
|
504
|
+
for (const args of setWindowMetaArgs(name, meta))
|
|
505
|
+
await this.tmux(args);
|
|
506
|
+
this.records.set(request.terminalId, { ...meta, windows: ['agent'] });
|
|
507
|
+
this.log(` ▶ code thread ${request.codeThreadId.slice(0, 12)}: ${request.agent} in ${checkout.path}`);
|
|
508
|
+
if (checkout.folder)
|
|
509
|
+
checkouts.folderStarted?.(checkout.folder);
|
|
510
|
+
// Started on a goal, Codex is working from its first second (keep-awake, `activity.ts`); its hook
|
|
511
|
+
// says only when a turn ends. Claude Code's own prompt hook marks its turns.
|
|
512
|
+
if (goal && request.agent === 'codex')
|
|
513
|
+
markTurn(request.terminalId, true, { PROHOST_HOME: this.options.home });
|
|
514
|
+
const reported = await reportStart();
|
|
515
|
+
// Claude Code may ask first (not pre-trusted, or the record didn't hold), and no hook says
|
|
516
|
+
// so: every new Claude window is watched, and the thread is told when it waits on it.
|
|
517
|
+
if (request.agent === 'claude_code')
|
|
518
|
+
void this.watchTrustPrompt(request.codeThreadId, request.terminalId, name, skips);
|
|
519
|
+
return { ok: true, adopted: false, reported };
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* A worktree this host made for the thread opens without Claude Code's trust dialog: no hook
|
|
523
|
+
* fires for it, so the thread would read `Working` while it waits for someone at the Mac.
|
|
524
|
+
* Only a worktree under this home's own `worktrees/`, never a checkout it was handed.
|
|
525
|
+
*/
|
|
526
|
+
async trustOwnWorktree(checkout, accountEnv) {
|
|
527
|
+
const root = worktreesRoot({ ...process.env, PROHOST_HOME: this.options.home });
|
|
528
|
+
const relative = path.relative(root, checkout.path);
|
|
529
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
530
|
+
return false;
|
|
531
|
+
const env = { ...process.env, ...stringEnv(accountEnv) };
|
|
532
|
+
// Only what the owner already trusts: their checkout (they accepted Claude Code's dialog
|
|
533
|
+
// there), and nothing it doesn't already have (`fromLocalHistory`, from the commit checked
|
|
534
|
+
// out): no remote branch's `.claude/` hooks that nobody here has seen.
|
|
535
|
+
if (checkout.fromLocalHistory !== true || !(this.options.trusts ?? claudeTrusts)(checkout.source, env))
|
|
536
|
+
return false;
|
|
537
|
+
const trusted = await (this.options.trustWorkspace ?? trustClaudeWorkspace)(checkout.path, env);
|
|
538
|
+
if (!trusted)
|
|
539
|
+
this.log(` ! could not pre-trust ${checkout.path} for Claude Code — it will ask on first run`);
|
|
540
|
+
return trusted;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Watch a new Claude Code window for its first-run screens, only to tell the thread it waits on
|
|
544
|
+
* one (needs-you, not Working): the folder's trust dialog, and, for a thread that skips permission
|
|
545
|
+
* prompts, Claude Code's one-time bypass warning (it comes up after the trust dialog is answered,
|
|
546
|
+
* so it is looked for longer once that was said). Each is said once. It never decides whether a
|
|
547
|
+
* reply types: the ready mark does.
|
|
548
|
+
*/
|
|
549
|
+
async watchTrustPrompt(codeThreadId, terminalId, name, skips = false) {
|
|
550
|
+
const screens = [
|
|
551
|
+
{ screen: TRUST_SCREEN, note: TRUST_PROMPT_NOTE, log: 'asks whether to trust its folder' },
|
|
552
|
+
...(skips
|
|
553
|
+
? [{ screen: BYPASS_SCREEN, note: BYPASS_PROMPT_NOTE, log: 'asks to confirm running without permission prompts' }]
|
|
554
|
+
: []),
|
|
555
|
+
];
|
|
556
|
+
const said = new Set();
|
|
557
|
+
let attempts = this.options.trustWatchAttempts ?? TRUST_WATCH_ATTEMPTS;
|
|
558
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
559
|
+
// A hook ran: it is past every first-run screen, whatever the screen shows.
|
|
560
|
+
if (this.isReady(terminalId) !== false)
|
|
561
|
+
return;
|
|
562
|
+
const pane = await this.tmux(['capture-pane', '-p', '-J', '-t', windowTarget(name)]);
|
|
563
|
+
if (!pane.ok)
|
|
564
|
+
return;
|
|
565
|
+
const shown = screens.find((entry) => !said.has(entry.note) && entry.screen.test(pane.stdout));
|
|
566
|
+
// Re-checked after the capture: a hook that ran meanwhile means the screen was just answered.
|
|
567
|
+
if (shown && this.isReady(terminalId) === false) {
|
|
568
|
+
this.log(` ! code thread ${codeThreadId.slice(0, 12)}: Claude Code ${shown.log}`);
|
|
569
|
+
await this.options.events.post(codeThreadId, event('stop', { finalText: shown.note, question: true }, this.now()));
|
|
570
|
+
said.add(shown.note);
|
|
571
|
+
if (said.size === screens.length)
|
|
572
|
+
return;
|
|
573
|
+
// The next screen waits on this one's answer: look for it from now, for longer.
|
|
574
|
+
attempts = attempt + 1 + Math.max(attempts, BYPASS_WATCH_ATTEMPTS);
|
|
575
|
+
}
|
|
576
|
+
await this.sleep(TRUST_POLL_MS);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async known(terminalId) {
|
|
580
|
+
if (!isValidId(terminalId))
|
|
581
|
+
return undefined;
|
|
582
|
+
if (!this.records.has(terminalId))
|
|
583
|
+
await this.adopt();
|
|
584
|
+
return this.records.get(terminalId);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Whether the thread's agent window runs an agent that composer text and
|
|
588
|
+
* approval keys may reach. A Claude Code or Codex that exited left a shell
|
|
589
|
+
* behind (see {@link agentCommandLine}); a `shell` thread is a shell from
|
|
590
|
+
* the start. Neither ever takes relayed text.
|
|
591
|
+
*/
|
|
592
|
+
async agentRunning(record) {
|
|
593
|
+
// A shell thread's window IS a shell: text relayed into it would run as a
|
|
594
|
+
// command. Remote typing into a shell happens only over an attached
|
|
595
|
+
// terminal (owner-only, remote switch on), never through composer relay.
|
|
596
|
+
if (record.agent === 'shell')
|
|
597
|
+
return false;
|
|
598
|
+
const shown = await this.tmux(['show-options', '-wqv', '-t', windowTarget(windowName(record.terminalId, 'agent')), EXITED_OPTION]);
|
|
599
|
+
return shown.ok && shown.stdout.trim() !== '1';
|
|
600
|
+
}
|
|
601
|
+
/** Send composer text to the thread's agent. False when the thread isn't here or its agent has exited. */
|
|
602
|
+
async input(request, outcome = {}) {
|
|
603
|
+
if (!isValidId(request.terminalId))
|
|
604
|
+
return false;
|
|
605
|
+
return this.serialize(request.terminalId, () => this.inputNow(request, outcome));
|
|
606
|
+
}
|
|
607
|
+
async inputNow(request, outcome) {
|
|
608
|
+
const record = await this.known(request.terminalId);
|
|
609
|
+
if (!record || record.codeThreadId !== request.codeThreadId) {
|
|
610
|
+
// Its window is gone (the Mac restarted, the session was closed): handed back, never "done".
|
|
611
|
+
this.log(` ! reply for code thread ${request.codeThreadId.slice(0, 12)} not typed: its terminal is gone`);
|
|
612
|
+
outcome.refused = 'terminal_gone';
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
this.touchLane(record);
|
|
616
|
+
if (!(await this.agentRunning(record))) {
|
|
617
|
+
this.log(` ! reply for code thread ${request.codeThreadId.slice(0, 12)} not typed: its window is a shell, not an agent`);
|
|
618
|
+
outcome.refused = 'shell_window';
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
if (record.agent === 'claude_code') {
|
|
622
|
+
// Typed only once a hook has run in the window (`ready.ts`): before that its trust dialog
|
|
623
|
+
// is up or about to be drawn, and an Enter would answer it. Never judged from the screen.
|
|
624
|
+
let ready = this.isReady(record.terminalId);
|
|
625
|
+
if (ready === false && !record.readyMark) {
|
|
626
|
+
// A window an older build started: its readiness pass may be running, or have failed
|
|
627
|
+
// (then this tries it once more, now, rather than at the next adopt).
|
|
628
|
+
await (this.readinessAdoption ?? this.adopt());
|
|
629
|
+
ready = this.isReady(record.terminalId);
|
|
630
|
+
}
|
|
631
|
+
if (ready === undefined) {
|
|
632
|
+
outcome.retry = true;
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
if (!ready) {
|
|
636
|
+
this.log(` ! reply for code thread ${request.codeThreadId.slice(0, 12)} not typed: no Claude Code hook has run in its ` +
|
|
637
|
+
'window yet (still starting, or a question in its terminal; if hooks are off in Claude Code\'s settings, ' +
|
|
638
|
+
'replies from ProhostAI can\'t be typed here)');
|
|
639
|
+
outcome.refused = 'trust_prompt';
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const target = windowTarget(windowName(record.terminalId, 'agent'));
|
|
644
|
+
const leftover = await this.tmux(['show-options', '-wqv', '-t', target, STAGED_OPTION]);
|
|
645
|
+
if (leftover.ok && leftover.stdout.trim() === '1') {
|
|
646
|
+
this.log(` ! reply for code thread ${request.codeThreadId.slice(0, 12)} not typed: an earlier reply was left ` +
|
|
647
|
+
'unsent in its input — type in its terminal to continue');
|
|
648
|
+
outcome.refused = 'reply_interrupted';
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
const commands = sendTextCommands(windowName(record.terminalId, 'agent'), request.text.slice(0, MAX_INPUT_CHARS));
|
|
652
|
+
/** The window carries {@link STAGED_OPTION} for this reply. */
|
|
653
|
+
let staged = false;
|
|
654
|
+
/** A step that can reach the agent's input box was sent (its outcome may be unknown). */
|
|
655
|
+
let reached = false;
|
|
656
|
+
for (const command of commands) {
|
|
657
|
+
if (command.args[0] === 'send-keys' && command.args.includes('Enter') && commands.length > 2) {
|
|
658
|
+
await this.sleep(PASTE_SETTLE_MS);
|
|
659
|
+
}
|
|
660
|
+
const touchesPane = command.args[0] !== 'load-buffer';
|
|
661
|
+
if (touchesPane && !staged) {
|
|
662
|
+
// Staged before the first keystroke, never after a failure: whatever
|
|
663
|
+
// stops this reply from here on leaves the window refusing relays.
|
|
664
|
+
if (!this.canType()) {
|
|
665
|
+
outcome.refused = 'remote_terminal_disabled';
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
staged = (await this.tmux(['set-option', '-w', '-t', target, STAGED_OPTION, '1'])).ok;
|
|
669
|
+
if (!staged) {
|
|
670
|
+
outcome.retry = true;
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
// Scrolled back, the pane would read the reply as copy-mode keys: it leaves copy mode first.
|
|
674
|
+
// Best effort; nothing typed yet.
|
|
675
|
+
if ((await this.tmux(leaveCopyModeArgs(windowName(record.terminalId, 'agent')))).ok) {
|
|
676
|
+
this.sizing.leftCopyMode(windowName(record.terminalId, 'agent'));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Before every step, not once: a revocation during a paste or its settle
|
|
680
|
+
// delay stops the rest (no Enter submits what was pasted).
|
|
681
|
+
const result = this.canType() ? await this.tmux(command.args, command.stdin) : undefined;
|
|
682
|
+
// Reached the pane only if the step ran: it succeeded, or was killed after it started. A clean
|
|
683
|
+
// failure (tmux exited with an error, or never started) touched nothing, so it is retried.
|
|
684
|
+
if (touchesPane && result && (result.ok || result.uncertain))
|
|
685
|
+
reached = true;
|
|
686
|
+
if (!result?.ok) {
|
|
687
|
+
if (reached) {
|
|
688
|
+
this.leaveStaged(request);
|
|
689
|
+
outcome.refused = 'reply_interrupted';
|
|
690
|
+
}
|
|
691
|
+
else if (staged) {
|
|
692
|
+
// Nothing was sent to the pane: the input box is as it was.
|
|
693
|
+
await this.tmux(['set-option', '-wu', '-t', target, STAGED_OPTION]);
|
|
694
|
+
if (!result)
|
|
695
|
+
outcome.refused = 'remote_terminal_disabled';
|
|
696
|
+
else
|
|
697
|
+
outcome.retry = true;
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
outcome.retry = true;
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Submitted: the agent took the input box's contents as a turn.
|
|
706
|
+
if (staged)
|
|
707
|
+
await this.tmux(['set-option', '-wu', '-t', target, STAGED_OPTION]);
|
|
708
|
+
// A reply submitted to Codex is a turn begun (its hook says only when one ends) — unless it is a
|
|
709
|
+
// slash command (`/status`, `/diff`), which starts no turn and would never be ended.
|
|
710
|
+
if (commands.length > 0 && record.agent === 'codex' && !request.text.trimStart().startsWith('/')) {
|
|
711
|
+
markTurn(record.terminalId, true, { PROHOST_HOME: this.options.home });
|
|
712
|
+
}
|
|
713
|
+
return commands.length > 0;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* A reply stopped after a step that can reach the agent's input box, before
|
|
717
|
+
* its Enter went through. Nothing is pressed to take it back: no keystroke
|
|
718
|
+
* from out here can tell the reply apart from what was in the box already —
|
|
719
|
+
* a draft typed before, or one the agent put back — so clearing it would
|
|
720
|
+
* erase that too. The window stays staged until the owner types in a
|
|
721
|
+
* terminal attached to it, where they see the input box. The delivery is reported unhandled (acked 204), never
|
|
722
|
+
* retried blind.
|
|
723
|
+
*/
|
|
724
|
+
leaveStaged(request) {
|
|
725
|
+
this.log(` ! reply for code thread ${request.codeThreadId.slice(0, 12)} stopped before it was sent and may be ` +
|
|
726
|
+
'left in its input; type in its terminal to continue');
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Mark a window just created with {@link holdCommand} as held, so its release signals it. When
|
|
730
|
+
* the mark can't be set, nothing would ever signal it: its shell is released now, unheld.
|
|
731
|
+
*/
|
|
732
|
+
async markHeld(name) {
|
|
733
|
+
if ((await this.tmux(['set-option', '-w', '-t', windowTarget(name), HOLD_OPTION, '1'])).ok)
|
|
734
|
+
return true;
|
|
735
|
+
await this.tmux(['wait-for', '-S', holdChannel(name)]);
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
/** The thread's shell window, made at `size` (its first viewer's) the first time someone asks. */
|
|
739
|
+
async ensureShellWindow(record, size) {
|
|
740
|
+
if (record.windows.includes('shell'))
|
|
741
|
+
return true;
|
|
742
|
+
const name = windowName(record.terminalId, 'shell');
|
|
743
|
+
await this.tmux(defaultSizeArgs(size.cols, size.rows));
|
|
744
|
+
const created = await this.tmux(newWindowArgs({
|
|
745
|
+
name,
|
|
746
|
+
cwd: record.cwd,
|
|
747
|
+
env: { PROHOST_HOME: this.options.home },
|
|
748
|
+
// Its first prompt waits for this viewer's size to settle (`sizing.ts`).
|
|
749
|
+
command: holdCommand(name, 'exec "${SHELL:-/bin/zsh}" -l'),
|
|
750
|
+
}));
|
|
751
|
+
if (created.ok) {
|
|
752
|
+
await this.tmux(resizeWindowArgs(name, size.cols, size.rows));
|
|
753
|
+
this.sizing.created(name, { held: await this.markHeld(name) });
|
|
754
|
+
}
|
|
755
|
+
// "duplicate window": a concurrent attach made it.
|
|
756
|
+
if (!created.ok)
|
|
757
|
+
await this.adopt();
|
|
758
|
+
const current = this.records.get(record.terminalId);
|
|
759
|
+
if (created.ok && current && !current.windows.includes('shell'))
|
|
760
|
+
current.windows.push('shell');
|
|
761
|
+
return created.ok || Boolean(current?.windows.includes('shell'));
|
|
762
|
+
}
|
|
763
|
+
noteInterrupt(record) {
|
|
764
|
+
// Esc / Ctrl+C ends a turn without Claude Code's Stop hook: no longer working (keep-awake).
|
|
765
|
+
markTurn(record.terminalId, false, { PROHOST_HOME: this.options.home });
|
|
766
|
+
const now = this.now();
|
|
767
|
+
// tmux may consume these bytes itself; only agent hook progress clears a menu guard.
|
|
768
|
+
if (now - (this.lastInterrupt.get(record.terminalId) ?? 0) < INTERRUPT_DEBOUNCE_MS)
|
|
769
|
+
return;
|
|
770
|
+
this.lastInterrupt.set(record.terminalId, now);
|
|
771
|
+
void this.options.events.post(record.codeThreadId, event('interrupted', {}, now));
|
|
772
|
+
}
|
|
773
|
+
async attach(request) {
|
|
774
|
+
if (!TERMINAL_WINDOWS.has(request.window))
|
|
775
|
+
return { ok: false, reason: 'invalid_window' };
|
|
776
|
+
const record = await this.known(request.terminalId);
|
|
777
|
+
if (!record)
|
|
778
|
+
return { ok: false, reason: 'unknown_terminal' };
|
|
779
|
+
this.touchLane(record);
|
|
780
|
+
const spawn = this.options.spawnPty();
|
|
781
|
+
if (!spawn)
|
|
782
|
+
return { ok: false, reason: 'pty_unavailable' };
|
|
783
|
+
if (this.openPtys >= MAX_OPEN_PTYS)
|
|
784
|
+
return { ok: false, reason: 'busy' };
|
|
785
|
+
const size = { cols: clampDim(request.cols, DEFAULT_COLS), rows: clampDim(request.rows, DEFAULT_ROWS) };
|
|
786
|
+
if (request.window === 'shell' && !(await this.ensureShellWindow(record, size))) {
|
|
787
|
+
return { ok: false, reason: 'unknown_terminal' };
|
|
788
|
+
}
|
|
789
|
+
const name = windowName(record.terminalId, request.window);
|
|
790
|
+
const view = viewSessionName();
|
|
791
|
+
let pty;
|
|
792
|
+
try {
|
|
793
|
+
pty = spawn(this.options.tmuxBinary ?? 'tmux', [...baseArgs(this.options.socket, this.options.confPath), ...attachArgs(name, view)], {
|
|
794
|
+
name: 'xterm-256color',
|
|
795
|
+
cols: size.cols,
|
|
796
|
+
rows: size.rows,
|
|
797
|
+
cwd: os.homedir(),
|
|
798
|
+
env: paneEnv({ ...stringEnv(process.env), TERM: 'xterm-256color' }),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
this.log(` ! terminal attach failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
803
|
+
return { ok: false, reason: 'pty_unavailable' };
|
|
804
|
+
}
|
|
805
|
+
this.openPtys += 1;
|
|
806
|
+
const watchOnly = request.mode === 'watch';
|
|
807
|
+
const sizer = this.sizing.join(name, { ...size, watch: watchOnly });
|
|
808
|
+
let closed = false;
|
|
809
|
+
const exitListeners = [];
|
|
810
|
+
// Listen from the first byte: tmux draws the screen as it attaches, and a
|
|
811
|
+
// relay viewer's socket connects only after this returns. Held (bounded)
|
|
812
|
+
// until the stream's consumer subscribes, then replayed in order.
|
|
813
|
+
let consumer;
|
|
814
|
+
let early = [];
|
|
815
|
+
let earlyBytes = 0;
|
|
816
|
+
pty.onData((data) => {
|
|
817
|
+
if (closed)
|
|
818
|
+
return;
|
|
819
|
+
const chunk = Buffer.from(data, 'utf8');
|
|
820
|
+
if (consumer) {
|
|
821
|
+
consumer(chunk);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
earlyBytes += chunk.length;
|
|
825
|
+
early.push(chunk);
|
|
826
|
+
while (earlyBytes > MAX_EARLY_OUTPUT_BYTES && early.length > 1)
|
|
827
|
+
earlyBytes -= early.shift().length;
|
|
828
|
+
});
|
|
829
|
+
const close = () => {
|
|
830
|
+
if (closed)
|
|
831
|
+
return;
|
|
832
|
+
closed = true;
|
|
833
|
+
this.openPtys -= 1;
|
|
834
|
+
sizer.leave();
|
|
835
|
+
try {
|
|
836
|
+
pty.kill();
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
/* already gone */
|
|
840
|
+
}
|
|
841
|
+
// destroy-unattached normally reaps it; this covers a pty killed before tmux attached.
|
|
842
|
+
void this.tmux(killSessionArgs(view));
|
|
843
|
+
};
|
|
844
|
+
pty.onExit(() => {
|
|
845
|
+
close();
|
|
846
|
+
for (const listener of exitListeners)
|
|
847
|
+
listener();
|
|
848
|
+
});
|
|
849
|
+
const isAgent = request.window === 'agent';
|
|
850
|
+
let tookOver = false;
|
|
851
|
+
/** A clear is queued and its result not yet known. */
|
|
852
|
+
let takingOver = false;
|
|
853
|
+
/** A person typed while that clear was in flight. */
|
|
854
|
+
let typedWhileTaking = false;
|
|
855
|
+
/** One chunk of a person's (or their emulator's) input, into the pane. */
|
|
856
|
+
const deliver = (data) => {
|
|
857
|
+
if (closed)
|
|
858
|
+
return;
|
|
859
|
+
if (isAgent && !tookOver && !takingOver && isHumanInput(data)) {
|
|
860
|
+
// The first keystroke from a person typing in the agent's window takes over whatever
|
|
861
|
+
// a half-sent reply left in its input box: they are at it, so relays may resume. Not
|
|
862
|
+
// an attach that fails, a socket that never connects, a viewer who leaves without
|
|
863
|
+
// typing, or the emulator's own reports (focus, size, mouse, replies); those leave the
|
|
864
|
+
// guard in place. Queued behind the reply in flight, if any, so one that stops after
|
|
865
|
+
// this still stages the window. Taken over only once the clear went through: a failed
|
|
866
|
+
// one is tried again at the next keystroke.
|
|
867
|
+
takingOver = true;
|
|
868
|
+
const target = windowTarget(windowName(record.terminalId, 'agent'));
|
|
869
|
+
void this.serialize(record.terminalId, async () => {
|
|
870
|
+
try {
|
|
871
|
+
// Typed again while a clear was in flight and it failed: try again in this same
|
|
872
|
+
// queued job, never behind later replies (a retry there could lift a newer guard).
|
|
873
|
+
for (;;) {
|
|
874
|
+
typedWhileTaking = false;
|
|
875
|
+
tookOver = (await this.tmux(['set-option', '-wu', '-t', target, STAGED_OPTION])).ok;
|
|
876
|
+
if (tookOver || !typedWhileTaking || closed)
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
finally {
|
|
881
|
+
takingOver = false;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
else if (isAgent && !tookOver && takingOver && isHumanInput(data)) {
|
|
886
|
+
typedWhileTaking = true;
|
|
887
|
+
}
|
|
888
|
+
// A wheel step, a drag or the prefix key: the pane may be in (or have left) copy mode. While
|
|
889
|
+
// it may be, a keystroke moves it too; an emulator report (focus, a reply) only re-reads it.
|
|
890
|
+
// Only a scroll (or a keystroke in the scrollback) makes this viewer the one who scrolled; a
|
|
891
|
+
// click or a report just re-reads the mode.
|
|
892
|
+
if (mayEnterCopyMode(data))
|
|
893
|
+
sizer.scrolled(isScrollGesture(data));
|
|
894
|
+
else if (this.sizing.mayBeScrolled(name))
|
|
895
|
+
sizer.scrolled(isHumanInput(data));
|
|
896
|
+
pty.write(Buffer.from(data).toString('utf8'));
|
|
897
|
+
if (isAgent && isInterruptInput(data))
|
|
898
|
+
this.noteInterrupt(record);
|
|
899
|
+
};
|
|
900
|
+
/** Input held while the pane leaves copy mode, in order; `undefined` when none is. */
|
|
901
|
+
let held;
|
|
902
|
+
const stream = {
|
|
903
|
+
onData: (listener) => {
|
|
904
|
+
consumer = listener;
|
|
905
|
+
const buffered = early;
|
|
906
|
+
early = [];
|
|
907
|
+
earlyBytes = 0;
|
|
908
|
+
for (const chunk of buffered)
|
|
909
|
+
listener(chunk);
|
|
910
|
+
},
|
|
911
|
+
onExit: (listener) => exitListeners.push(listener),
|
|
912
|
+
write: (data) => {
|
|
913
|
+
if (closed || watchOnly || data.length === 0)
|
|
914
|
+
return;
|
|
915
|
+
if (held) {
|
|
916
|
+
held.push(data);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// Navigation keys (arrows, paging, other escape sequences and control keys) still move
|
|
920
|
+
// through the scrollback of the viewer who scrolled; only text leaves copy mode on its way
|
|
921
|
+
// to the agent. Anything another viewer types leaves someone else's copy mode first.
|
|
922
|
+
const scrolled = this.sizing.mayBeScrolled(name);
|
|
923
|
+
const othersScroll = scrolled && !sizer.isScroller() && isHumanInput(data);
|
|
924
|
+
if (!scrolled || (!othersScroll && !(isTypedText(data) || isCopyModeExitKey(data)))) {
|
|
925
|
+
// Focusing this terminal sizes the window to it; typing does once the sizing viewer is idle (`sizing.ts`).
|
|
926
|
+
noteInputTakeover(sizer, data);
|
|
927
|
+
deliver(data);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// Someone scrolled back since the last keystroke: in copy mode, text typed would be read
|
|
931
|
+
// as copy-mode keys and never reach the agent. Leave it first; q / Esc / Ctrl+C pressed in
|
|
932
|
+
// it only leave it, as they always did. The claim waits until then: a handover leaves copy
|
|
933
|
+
// mode too, and would turn that q into a keystroke.
|
|
934
|
+
held = [data];
|
|
935
|
+
void (async () => {
|
|
936
|
+
const read = await this.tmux(paneInModeArgs(name));
|
|
937
|
+
const inMode = read.stdout.trim() === '1';
|
|
938
|
+
// Out of copy mode only once tmux says so: a read or a cancel it refused leaves it as it was.
|
|
939
|
+
const left = read.ok && (!inMode || (await this.tmux(['send-keys', '-X', '-t', windowTarget(name), 'cancel'])).ok);
|
|
940
|
+
if (left)
|
|
941
|
+
this.sizing.leftCopyMode(name);
|
|
942
|
+
noteInputTakeover(sizer, data);
|
|
943
|
+
const pending = held ?? [];
|
|
944
|
+
held = undefined;
|
|
945
|
+
pending.forEach((chunk, index) => {
|
|
946
|
+
if (index === 0 && inMode && isCopyModeExitKey(chunk))
|
|
947
|
+
return;
|
|
948
|
+
if (index > 0)
|
|
949
|
+
noteInputTakeover(sizer, chunk);
|
|
950
|
+
deliver(chunk);
|
|
951
|
+
});
|
|
952
|
+
})();
|
|
953
|
+
},
|
|
954
|
+
resize: (cols, rows) => {
|
|
955
|
+
if (closed)
|
|
956
|
+
return;
|
|
957
|
+
const next = { cols: clampDim(cols, DEFAULT_COLS), rows: clampDim(rows, DEFAULT_ROWS) };
|
|
958
|
+
try {
|
|
959
|
+
pty.resize(next.cols, next.rows);
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
/* pty gone */
|
|
963
|
+
}
|
|
964
|
+
sizer.resize(next.cols, next.rows);
|
|
965
|
+
},
|
|
966
|
+
close,
|
|
967
|
+
hello: (client) => {
|
|
968
|
+
if (!closed)
|
|
969
|
+
sizer.hello(client);
|
|
970
|
+
},
|
|
971
|
+
claim: () => {
|
|
972
|
+
if (!closed)
|
|
973
|
+
sizer.claim();
|
|
974
|
+
},
|
|
975
|
+
onSizer: (listener) => sizer.onSizer(listener),
|
|
976
|
+
history: (lines) => closed ? Promise.resolve({ ok: false, error: 'unavailable' }) : readHistory((args) => this.tmux(args), name, lines),
|
|
977
|
+
};
|
|
978
|
+
return { ok: true, stream };
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function stringEnv(env) {
|
|
982
|
+
const out = {};
|
|
983
|
+
for (const [key, value] of Object.entries(env))
|
|
984
|
+
if (typeof value === 'string')
|
|
985
|
+
out[key] = value;
|
|
986
|
+
return out;
|
|
987
|
+
}
|
|
988
|
+
/** `[[dd-]hh:]mm:ss` from `ps -o etime` as seconds, or `undefined`. */
|
|
989
|
+
function etimeSeconds(etime) {
|
|
990
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
|
|
991
|
+
if (!match)
|
|
992
|
+
return undefined;
|
|
993
|
+
const [, days = '0', hours = '0', minutes = '0', seconds = '0'] = match;
|
|
994
|
+
return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* When each of `pids` started, from one `ps -o pid=,etime=` (bounded, asynchronous: the daemon
|
|
998
|
+
* keeps serving meanwhile). From elapsed time, which no timezone can shift, a second early since
|
|
999
|
+
* `etime` drops the part of the current second already gone. A pid `ps` doesn't list is left out
|
|
1000
|
+
* (`ps` exits 1 with nothing on stderr when none of them run). Rejects on any other outcome (it
|
|
1001
|
+
* timed out, was killed, couldn't start, exited with another status, or wrote an error such as a
|
|
1002
|
+
* bad option's usage, which also exits 1), so the caller tries again rather than taking "none
|
|
1003
|
+
* started".
|
|
1004
|
+
*/
|
|
1005
|
+
export function paneStarts(pids, now = Date.now(), run = execFile) {
|
|
1006
|
+
return new Promise((resolve, reject) => {
|
|
1007
|
+
run('ps', ['-o', 'pid=,etime=', '-p', pids.join(',')], { timeout: 2_000 }, (error, stdout, stderr) => {
|
|
1008
|
+
// `code` is the exit status (a number) when ps ran, or an errno string when it couldn't start.
|
|
1009
|
+
const failure = error;
|
|
1010
|
+
// Exit 1 with a silent stderr is `ps -p` finding none of the pids running. Nothing else is
|
|
1011
|
+
// an answer: a bad option exits 1 too, but prints its usage.
|
|
1012
|
+
if (failure && (failure.code !== 1 || String(stderr ?? '').trim() !== '')) {
|
|
1013
|
+
reject(new Error(`ps did not answer: ${failure.message}`));
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const starts = new Map();
|
|
1017
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
1018
|
+
const [pid, etime] = line.trim().split(/\s+/);
|
|
1019
|
+
const seconds = etime ? etimeSeconds(etime) : undefined;
|
|
1020
|
+
if (pid && /^\d+$/.test(pid) && seconds !== undefined) {
|
|
1021
|
+
starts.set(Number(pid), Math.floor(now / 1000) * 1000 - (seconds + 1) * 1000);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
resolve(starts);
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
}
|