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