@prohost/cli 0.13.3 → 0.14.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +129 -0
  3. package/dist/agent/command.d.ts +8 -0
  4. package/dist/agent/command.js +40 -7
  5. package/dist/agent/daemon.d.ts +3 -1
  6. package/dist/agent/daemon.js +1 -2
  7. package/dist/agent/run.d.ts +30 -1
  8. package/dist/agent/run.js +221 -9
  9. package/dist/agent/runtime_status.d.ts +99 -3
  10. package/dist/agent/runtime_status.js +173 -5
  11. package/dist/agent/worktrees.d.ts +2 -0
  12. package/dist/agent/worktrees.js +3 -2
  13. package/dist/codehost/activity.d.ts +64 -0
  14. package/dist/codehost/activity.js +135 -0
  15. package/dist/codehost/agents.d.ts +31 -0
  16. package/dist/codehost/agents.js +57 -0
  17. package/dist/codehost/checkouts.d.ts +130 -0
  18. package/dist/codehost/checkouts.js +270 -0
  19. package/dist/codehost/claude_trust.d.ts +40 -0
  20. package/dist/codehost/claude_trust.js +129 -0
  21. package/dist/codehost/command.d.ts +25 -0
  22. package/dist/codehost/command.js +136 -0
  23. package/dist/codehost/decision.d.ts +156 -0
  24. package/dist/codehost/decision.js +553 -0
  25. package/dist/codehost/events.d.ts +49 -0
  26. package/dist/codehost/events.js +87 -0
  27. package/dist/codehost/folders.d.ts +122 -0
  28. package/dist/codehost/folders.js +317 -0
  29. package/dist/codehost/hooks.d.ts +178 -0
  30. package/dist/codehost/hooks.js +726 -0
  31. package/dist/codehost/host.d.ts +290 -0
  32. package/dist/codehost/host.js +625 -0
  33. package/dist/codehost/host_stats.d.ts +71 -0
  34. package/dist/codehost/host_stats.js +166 -0
  35. package/dist/codehost/invocations.d.ts +12 -0
  36. package/dist/codehost/invocations.js +82 -0
  37. package/dist/codehost/ledger.d.ts +35 -0
  38. package/dist/codehost/ledger.js +91 -0
  39. package/dist/codehost/local_server.d.ts +106 -0
  40. package/dist/codehost/local_server.js +339 -0
  41. package/dist/codehost/pty.d.ts +38 -0
  42. package/dist/codehost/pty.js +47 -0
  43. package/dist/codehost/ready.d.ts +31 -0
  44. package/dist/codehost/ready.js +109 -0
  45. package/dist/codehost/relay.d.ts +170 -0
  46. package/dist/codehost/relay.js +548 -0
  47. package/dist/codehost/settings.d.ts +52 -0
  48. package/dist/codehost/settings.js +114 -0
  49. package/dist/codehost/sizing.d.ts +163 -0
  50. package/dist/codehost/sizing.js +394 -0
  51. package/dist/codehost/terminals.d.ts +390 -0
  52. package/dist/codehost/terminals.js +965 -0
  53. package/dist/codehost/tmux.d.ts +196 -0
  54. package/dist/codehost/tmux.js +391 -0
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.js +21 -0
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +4 -1
package/dist/agent/run.js CHANGED
@@ -27,10 +27,12 @@
27
27
  * way a stop pressed in the UI reaches a machine that accepts no connections.
28
28
  */
29
29
  import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
30
32
  import { spawnSync } from 'node:child_process';
31
33
  import WebSocket from 'ws';
32
34
  import { HarnessAccount } from './account_runtime.js';
33
- import { EVENT_AGENT_CONFIG_UPDATED, machineId } from './accounts.js';
35
+ import { EVENT_AGENT_CONFIG_UPDATED, accountEnv, machineId } from './accounts.js';
34
36
  import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
35
37
  import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
36
38
  import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, codexFailureMessage, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
@@ -45,11 +47,13 @@ import { buildPrompt, replyFromStdout } from './prompt.js';
45
47
  import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
46
48
  import { RunStore } from './runstore.js';
47
49
  import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
48
- import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
50
+ import { CODE_HOST_FOLDERS_FEATURE, RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, shortHostname, } from './runtime_status.js';
49
51
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
50
52
  import { verifySignature } from './signature.js';
51
53
  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';
52
54
  import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
55
+ import { CODE_THREAD_FOLDERS_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 { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
53
57
  import { isWebhookEvent } from '../forward.js';
54
58
  import { DEFAULT_WS_URL } from '../listen.js';
55
59
  import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from '../liveness.js';
@@ -105,9 +109,15 @@ const HANDSHAKE_TIMEOUT_MS = 30_000;
105
109
  * uses it to stop a run from sitting on CI after the PR is open.
106
110
  */
107
111
  export const PAIRED_RUN_ENV_VAR = 'PROHOST_PAIRED_RUN';
112
+ /** What a run sent to a `--host-only` harness is answered with: it never executes. */
113
+ export const HOST_ONLY_REFUSAL = "This computer only hosts Code threads; it doesn't answer messages.";
114
+ /** Declared by a `--host-only` harness: it executes no runs (the server may say so, or stop sending them). */
115
+ export const HOST_ONLY_CAPABILITY = 'host_only';
108
116
  /** Ack status codes reported back over the socket for each frame. */
109
117
  const ACK_ACCEPTED = 200;
110
118
  const ACK_IGNORED = 204;
119
+ /** A Code-thread reply or decision refused before anything was typed; the ack names why. */
120
+ const ACK_REFUSED = 409;
111
121
  const ACK_REJECTED = 401;
112
122
  /** Received, but the run could not be taken on yet: the server keeps retrying the delivery. */
113
123
  const ACK_RETRY = 503;
@@ -1280,6 +1290,63 @@ mcp) {
1280
1290
  * Reconnects on any drop with jittered exponential backoff; an ``auth_error``
1281
1291
  * is fatal, because retrying a rejected credential just hammers the server.
1282
1292
  */
1293
+ /**
1294
+ * The login a Code thread's agent runs on: the account picked for this
1295
+ * agent, as its ordinary runs get it. A thread for the other CLI, or an
1296
+ * account that is no longer on this machine, is refused rather than run on
1297
+ * some other login — it would spend a subscription the agent doesn't report.
1298
+ * A harness without accounts (a plain `--exec`) and shell threads use the
1299
+ * operator's default login, exactly as everything else they run does.
1300
+ */
1301
+ export function codeThreadAccountEnv(account, agent) {
1302
+ if (!account || agent === 'shell')
1303
+ return { ok: true, env: {} };
1304
+ if (account.runtime !== agent) {
1305
+ const name = (runtime) => (runtime === 'codex' ? 'Codex' : 'Claude Code');
1306
+ return {
1307
+ ok: false,
1308
+ reason: `this Mac's agent runs ${name(account.runtime)}, so it can't host a ${name(agent)} thread`,
1309
+ };
1310
+ }
1311
+ const resolved = account.resolve();
1312
+ return resolved.ok ? { ok: true, env: accountEnv(resolved.record) } : { ok: false, reason: resolved.error };
1313
+ }
1314
+ /**
1315
+ * The Code-thread host, when this machine's owner turned hosting on — else
1316
+ * `undefined`, and nothing about the harness changes.
1317
+ */
1318
+ function buildCodeHost(options, machine, id, log, slots, accountEnv, onRemoteChanged, onCodeHostChanged) {
1319
+ const env = { ...process.env, ...options.env };
1320
+ if (!loadCodeHostSettings(env).enabled)
1321
+ return undefined;
1322
+ // The same id the event socket authenticates with, so both name one machine.
1323
+ if (!id) {
1324
+ log(`${timestamp()} ! Code-thread hosting is on, but this machine has no id yet — not hosting`);
1325
+ return undefined;
1326
+ }
1327
+ try {
1328
+ return new CodeHost({
1329
+ env,
1330
+ baseUrl: options.credentials.base_url,
1331
+ apiKey: options.credentials.api_key,
1332
+ machineId: id,
1333
+ machine,
1334
+ clientVersion: CLI_VERSION,
1335
+ accountId: options.credentials.prohost_account?.id,
1336
+ repos: options.repos ?? [],
1337
+ cliEntrypoint: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js'),
1338
+ log,
1339
+ slots,
1340
+ accountEnv,
1341
+ onRemoteChanged,
1342
+ onCodeHostChanged,
1343
+ });
1344
+ }
1345
+ catch (err) {
1346
+ log(`${timestamp()} ! Code-thread hosting unavailable: ${err instanceof Error ? err.message : String(err)}`);
1347
+ return undefined;
1348
+ }
1349
+ }
1283
1350
  export async function runAgentHarness(options) {
1284
1351
  const log = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
1285
1352
  const Ctor = options.webSocketCtor ?? WebSocket;
@@ -1288,9 +1355,19 @@ export async function runAgentHarness(options) {
1288
1355
  const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
1289
1356
  const metrics = newMetrics();
1290
1357
  const runtime = buildRuntime(options, log);
1358
+ if (options.hostOnly && options.credentials.account && options.credentials.account !== 'default') {
1359
+ // Host-only runs no agent of its own, so it has no account to pick: never a silent switch.
1360
+ log(`${timestamp()} ! --host-only: this pairing's account "${options.credentials.account}" is not used — ` +
1361
+ "Code threads run on this Mac's default Claude Code / Codex login");
1362
+ }
1291
1363
  // Keys this computer's accounts server-side; the label above is display
1292
1364
  // only. Only a runtime that reports accounts needs one.
1293
- const machineIdValue = runtime.account ? machineId({ ...process.env, ...options.env }) : undefined;
1365
+ // Hosting Code threads needs it too: the server finds a Mac by this id.
1366
+ const hostingCodeThreads = !options.dryRun &&
1367
+ (options.codeHost !== undefined
1368
+ ? options.codeHost !== null
1369
+ : loadCodeHostSettings({ ...process.env, ...options.env }).enabled);
1370
+ const machineIdValue = runtime.account || hostingCodeThreads ? machineId({ ...process.env, ...options.env }) : undefined;
1294
1371
  // A credential file written by 0.1.0 stores a socket URL on a host that only
1295
1372
  // ever answers 403. Upgrading the CLI would not have fixed those installs on
1296
1373
  // its own — the stored value wins over the constant — so say what happened
@@ -1331,7 +1408,30 @@ export async function runAgentHarness(options) {
1331
1408
  accountKeys: runtime.account ? () => runtime.account.keys() : undefined,
1332
1409
  // A run in flight reports fresh numbers when it ends; don't probe beside it.
1333
1410
  isBusy: () => [...accepted.values()].some((entry) => entry.executing),
1334
- capabilities: () => (runtime.venueSessions ? [VENUE_RUN_TOKEN_CAPABILITY] : []),
1411
+ capabilities: () => [
1412
+ ...(runtime.venueSessions ? [VENUE_RUN_TOKEN_CAPABILITY] : []),
1413
+ ...(options.hostOnly ? [HOST_ONLY_CAPABILITY] : []),
1414
+ // Only a harness that hosts threads and handles `code_thread.*` says so:
1415
+ // the server sends a Mac start only to a socket that declared it.
1416
+ // Only while the relay hasn't refused the pairing: the server lists and admits on it.
1417
+ // With which instance this home is: the server binds each thread to the one that started it.
1418
+ // With folder starts (`folder` / `separateCopy`): the server sends one only to a socket saying so.
1419
+ // With the agents whose binaries resolve here, re-checked per frame: the server offers only those.
1420
+ ...(codeHost?.authorized()
1421
+ ? [CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability(), ...(codeHost.agentCapabilities?.() ?? [])]
1422
+ : []),
1423
+ // Read on every frame, so `remote off` stops being declared at the next one.
1424
+ ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed() ? [REMOTE_TERMINAL_CAPABILITY] : []),
1425
+ ],
1426
+ // `--machine` names this machine everywhere, exactly as given: whoever set it never sends the
1427
+ // real hostname. Only the real hostname is cut to its short form.
1428
+ hostname: () => (options.machine == null ? shortHostname() : options.machine),
1429
+ // Only while hosting Code threads: the owner's opt-in covers sending this machine's load.
1430
+ host: () => codeHost?.latestStats(),
1431
+ // What a person may pick for a thread on this Mac; the server lists these and refuses others.
1432
+ codeHostRepos: () => (codeHost?.authorized() ? codeHost.offeredRepos?.() : undefined),
1433
+ // The folders this Mac has opened, and its home (for `~/…`): what a phone may start a thread in.
1434
+ codeHostFolders: () => (codeHost?.authorized() ? codeHost.openedFolders?.() : undefined),
1335
1435
  log,
1336
1436
  });
1337
1437
  runtime.observe = (observation) => status.observe(observation);
@@ -1394,6 +1494,17 @@ export async function runAgentHarness(options) {
1394
1494
  const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)));
1395
1495
  const scheduler = new RunScheduler(concurrency);
1396
1496
  const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
1497
+ const codeHost = !hostingCodeThreads
1498
+ ? undefined
1499
+ : options.codeHost === undefined
1500
+ ? buildCodeHost(options, machine, machineIdValue, log, () => ({ busy: scheduler.active, total: concurrency }), (agent) => codeThreadAccountEnv(runtime.account, agent), () => status.capabilitiesChanged(), () => status.codeHostChanged())
1501
+ : (options.codeHost ?? undefined);
1502
+ if (codeHost) {
1503
+ await codeHost.start().catch((err) => {
1504
+ log(`${timestamp()} ! Code-thread hosting did not start: ${err instanceof Error ? err.message : String(err)}`);
1505
+ });
1506
+ }
1507
+ let codeHostIgnoredLogged = false;
1397
1508
  if (concurrency > 1) {
1398
1509
  log(`${timestamp()} ⚙ up to ${concurrency} runs at once${options.concurrency === undefined ? ' (the default; --concurrency 1 for one at a time)' : ''} — runs in the same conversation still take turns` +
1399
1510
  (worktrees.enabled
@@ -1510,6 +1621,13 @@ export async function runAgentHarness(options) {
1510
1621
  log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
1511
1622
  await sleep(delay);
1512
1623
  }
1624
+ // Remote terminals end the moment the harness does — before draining runs,
1625
+ // which can take hours: nothing may attach or type after a stop. The
1626
+ // terminals themselves stay (tmux owns them; the next start re-adopts them).
1627
+ // Fail-soft: a cleanup error must never keep an in-flight run from reporting.
1628
+ await codeHost?.stop().catch((err) => {
1629
+ log(`${timestamp()} ! stopping Code-thread hosting: ${err instanceof Error ? err.message : String(err)}`);
1630
+ });
1513
1631
  // Let an in-flight run finish reporting before we hand back metrics — and
1514
1632
  // any cancellation the heartbeat loop started and deliberately did not wait
1515
1633
  // for, so the harness never exits with a run half-closed.
@@ -1645,8 +1763,11 @@ export async function runAgentHarness(options) {
1645
1763
  // mentions" describes a narrower trigger than the one this harness
1646
1764
  // actually waits on, and sends anyone debugging a silent agent to
1647
1765
  // read mention code instead of the run dispatcher.
1648
- log(`${timestamp()} ● connected as "${name}" — waiting for ` +
1649
- `${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
1766
+ log(options.hostOnly
1767
+ ? `${timestamp()} ● connected as "${name}" — hosting Code threads only; ` +
1768
+ `every ${EVENT_AGENT_RUN_REQUESTED} is refused, nothing is executed`
1769
+ : `${timestamp()} ● connected as "${name}" — waiting for ` +
1770
+ `${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
1650
1771
  // Only a server that says it understands the frame gets one; an
1651
1772
  // older server may treat an unknown frame type as a protocol error.
1652
1773
  // A pick made while this agent was offline: the server repeats it on
@@ -1663,7 +1784,10 @@ export async function runAgentHarness(options) {
1663
1784
  venueSessionsOffered = Array.isArray(f.features) && f.features.includes(VENUE_SESSION_FEATURE);
1664
1785
  if (Array.isArray(f.features) && f.features.includes(RUNTIME_STATUS_FEATURE)) {
1665
1786
  primeStatus();
1666
- status.attach((frame) => ws.send(JSON.stringify(frame)));
1787
+ // A server that takes the opened folders whole (64 KiB frames) and early when they change.
1788
+ status.attach((frame) => ws.send(JSON.stringify(frame)), {
1789
+ codeHostFolders: f.features.includes(CODE_HOST_FOLDERS_FEATURE),
1790
+ });
1667
1791
  }
1668
1792
  return;
1669
1793
  }
@@ -1694,9 +1818,9 @@ export async function runAgentHarness(options) {
1694
1818
  if (!isWebhookEvent(frame))
1695
1819
  return;
1696
1820
  metrics.eventsReceived += 1;
1697
- const ack = (status) => {
1821
+ const ack = (status, reason) => {
1698
1822
  try {
1699
- ws.send(JSON.stringify({ type: 'ack', delivery_id: frame.delivery_id, status }));
1823
+ ws.send(JSON.stringify({ type: 'ack', delivery_id: frame.delivery_id, status, ...(reason ? { reason } : {}) }));
1700
1824
  }
1701
1825
  catch {
1702
1826
  /* best-effort */
@@ -1717,6 +1841,72 @@ export async function runAgentHarness(options) {
1717
1841
  ack(applied ? ACK_ACCEPTED : ACK_IGNORED);
1718
1842
  return;
1719
1843
  }
1844
+ if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
1845
+ frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
1846
+ frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED) {
1847
+ if (!codeHost) {
1848
+ if (!codeHostIgnoredLogged) {
1849
+ codeHostIgnoredLogged = true;
1850
+ log(options.dryRun
1851
+ ? `${timestamp()} ◦ ${frame.event} ignored — [dry-run] Code-thread actions are disabled`
1852
+ : `${timestamp()} ◦ ${frame.event} ignored — this machine does not host Code threads ` +
1853
+ '(prohost agent code-host enable)');
1854
+ }
1855
+ metrics.ignored += 1;
1856
+ ack(ACK_IGNORED);
1857
+ return;
1858
+ }
1859
+ // It starts processes on this machine, so it is held to the run's signature rule.
1860
+ if (!verifySignature(options.credentials.webhook_secret, frame.payload, frame.signature) &&
1861
+ !options.allowUnverified) {
1862
+ metrics.signatureRejections += 1;
1863
+ log(`${timestamp()} ✗ rejected ${frame.event} — signature did not verify`);
1864
+ ack(ACK_REJECTED);
1865
+ return;
1866
+ }
1867
+ const data = eventData(frame.payload);
1868
+ if (!codeHost.admits(frame.event, data)) {
1869
+ log(`${timestamp()} ◦ ${frame.event} refused — it needs the remote terminal on ` +
1870
+ '(prohost agent code-host remote on)');
1871
+ metrics.ignored += 1;
1872
+ // A reply or decision refused only for the switch: 409 + why, so the server hands it
1873
+ // back on the thread rather than count it delivered.
1874
+ const refusal = codeHost.refusal(frame.event);
1875
+ if (refusal)
1876
+ ack(ACK_REFUSED, refusal);
1877
+ else
1878
+ ack(ACK_IGNORED);
1879
+ return;
1880
+ }
1881
+ const starting = frame.event === EVENT_CODE_THREAD_START_REQUESTED;
1882
+ const handled = starting
1883
+ ? codeHost.handleStart(data)
1884
+ : frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED
1885
+ ? codeHost.handleInput(data, frame.delivery_id)
1886
+ : codeHost.handleApproval(data, frame.delivery_id);
1887
+ // Acknowledged once handled, never before: a start acked and then
1888
+ // lost to a crash or a shutdown would leave its thread starting
1889
+ // forever. A start that ran — or failed, which it reports — is 200;
1890
+ // one that threw is retried, and the redelivery finds any window it
1891
+ // made (or joins the start still in flight). Input and approvals are
1892
+ // 200 when they reached a pane here, 409 + reason when refused before
1893
+ // typing anything (the server hands them back), else 204.
1894
+ background.push(handled
1895
+ .then((ok) => {
1896
+ ack(starting || ok ? ACK_ACCEPTED : ACK_IGNORED);
1897
+ })
1898
+ .catch((err) => {
1899
+ if (err instanceof RefusedDeliveryError) {
1900
+ log(`${timestamp()} ◦ ${frame.event} refused before typing anything: ${err.reason}`);
1901
+ metrics.ignored += 1;
1902
+ ack(ACK_REFUSED, err.reason);
1903
+ return;
1904
+ }
1905
+ log(`${timestamp()} ✗ ${frame.event} failed: ${err instanceof Error ? err.message : String(err)}`);
1906
+ ack(ACK_RETRY);
1907
+ }));
1908
+ return;
1909
+ }
1720
1910
  if (frame.event !== EVENT_AGENT_RUN_REQUESTED) {
1721
1911
  if (frame.event === EVENT_MENTION_CREATED || frame.event === EVENT_MESSAGE_TEAM_CHAT) {
1722
1912
  // "runs are triggered by mentions" was wrong twice over: the gate
@@ -1761,6 +1951,28 @@ export async function runAgentHarness(options) {
1761
1951
  handledThisSession.add(run.run_id);
1762
1952
  /** Take the run on: track it, heartbeat it, queue it. */
1763
1953
  const accept = (run) => {
1954
+ if (options.hostOnly && !previous) {
1955
+ // Never executed: closed out as failed at once, through the run's own completion
1956
+ // path, so the person who mentioned this machine hears why instead of waiting.
1957
+ metrics.runsFailed += 1;
1958
+ if (options.dryRun) {
1959
+ log(`${timestamp()} [dry-run] would refuse run ${run.run_id.slice(0, 12)} — this harness only hosts Code threads`);
1960
+ return;
1961
+ }
1962
+ log(`${timestamp()} ⊘ run ${run.run_id.slice(0, 12)} refused — this harness only hosts Code threads`);
1963
+ background.push(completeWithRetry(apiForRun(api, run), run.completion_path, { ok: false, error: HOST_ONLY_REFUSAL }, log, sleep)
1964
+ .then((closed) => {
1965
+ if (closed.ok)
1966
+ runs.markCompleted(run.run_id);
1967
+ else
1968
+ handledThisSession.delete(run.run_id);
1969
+ })
1970
+ .catch((err) => {
1971
+ handledThisSession.delete(run.run_id);
1972
+ log(`${timestamp()} ✗ refusing run ${run.run_id.slice(0, 12)} failed: ${String(err)}`);
1973
+ }));
1974
+ return;
1975
+ }
1764
1976
  if (previous) {
1765
1977
  // We ran this one before but never got a terminal state accepted —
1766
1978
  // most likely the process died between the command and the
@@ -36,6 +36,34 @@ export declare const STATUS_INTERVAL_MS: number;
36
36
  * the frame that carried a change.
37
37
  */
38
38
  export declare const MIN_FRAME_SPACING_MS = 31000;
39
+ /**
40
+ * Inside that window the server still accepts a frame whose capabilities
41
+ * changed, one per 5 s (`MIN_CAPABILITY_CHANGE_INTERVAL_SECONDS`), and a strict
42
+ * narrowing (a capability dropped, nothing added) one per second
43
+ * (`MIN_CAPABILITY_NARROWING_INTERVAL_SECONDS`). A capability can be a switch
44
+ * the server enforces (`remote_terminal`). Margin on both.
45
+ */
46
+ export declare const MIN_CAPABILITY_CHANGE_SPACING_MS = 6000;
47
+ export declare const MIN_CAPABILITY_NARROWING_SPACING_MS = 1500;
48
+ /**
49
+ * The server drops a status frame over 8 KB (`MAX_FRAME_BYTES`). The opened
50
+ * folders are the one part of it that can grow that far, so they are cut,
51
+ * oldest first, until the frame fits under this, with margin.
52
+ */
53
+ export declare const MAX_STATUS_FRAME_BYTES = 8000;
54
+ /**
55
+ * The budget once the server lists {@link CODE_HOST_FOLDERS_FEATURE} in `auth_ok.features`: it
56
+ * takes frames up to 64 KiB, so all 50 folders fit. Margin under the cap.
57
+ */
58
+ export declare const MAX_LARGE_STATUS_FRAME_BYTES: number;
59
+ /**
60
+ * A server listing this feature takes a status frame up to 64 KiB, and one whose
61
+ * `code_host_repos` / `code_host_folders` / `code_host_home` changed inside its spacing window
62
+ * (a 5 s floor) — the opened folders reach phones within seconds of `folders add`.
63
+ */
64
+ export declare const CODE_HOST_FOLDERS_FEATURE = "code_host_folders";
65
+ /** The early code-host frame's spacing: the server's 5 s floor, with margin. */
66
+ export declare const MIN_CODE_HOST_CHANGE_SPACING_MS = 6000;
39
67
  /** Ask the agent CLI for fresh numbers when the last ones are this old. */
40
68
  export declare const QUOTA_REFRESH_AFTER_MS: number;
41
69
  /** Ceiling on one idle quota probe. */
@@ -80,8 +108,10 @@ export declare function fromClaudeRateLimitInfo(info: unknown): QuotaWindow[];
80
108
  * Windows from a `get_usage` control response (0–100, ISO strings).
81
109
  *
82
110
  * A window with a usage but a `null` reset is kept, with `resets_at: null` —
83
- * that is how an idle account's 5-hour window reads, and dropping it is what
84
- * left paired agents showing a weekly window and nothing else (B3).
111
+ * that is how an idle account's 5-hour window reads. Reporting stays complete:
112
+ * dropping it here, with the list replaced whole by each source, erased a known
113
+ * 5-hour window (B3). Whether to show an idle one is the server's call (it
114
+ * doesn't: `shared.ai_usage.windows.visible_windows`).
85
115
  *
86
116
  * Per-model weekly windows come from `rate_limits.limits[]` rows of kind
87
117
  * `weekly_scoped` (classified on `kind`, as the schema asks, never on a
@@ -181,9 +211,16 @@ export interface AccountsSnapshot {
181
211
  */
182
212
  all?: AccountBlock[];
183
213
  }
214
+ /** This machine's hostname up to its first dot (`studio-001.local` is `studio-001`), or `undefined`. */
215
+ export declare function shortHostname(raw?: string): string | undefined;
184
216
  export interface RuntimeStatusReporterOptions {
185
217
  /** `claude_code` | `codex` | `goose` | `custom`. */
186
218
  runtime: string;
219
+ /**
220
+ * The machine's short hostname, for ProhostAI staff reading a run's details. Defaults to
221
+ * {@link shortHostname}; `agent run` passes its `--machine` name when one was given.
222
+ */
223
+ hostname?: () => string | undefined;
187
224
  /** Idle quota source; omit for a runtime that has none. */
188
225
  provider?: QuotaProvider;
189
226
  /**
@@ -212,6 +249,29 @@ export interface RuntimeStatusReporterOptions {
212
249
  * frame, so a downgraded harness stops declaring what it no longer does.
213
250
  */
214
251
  capabilities?: () => string[];
252
+ /**
253
+ * This machine's load (`codehost/host_stats.ts`), read for every frame. Only
254
+ * set while Code-thread hosting is on — the owner's opt-in — so a harness
255
+ * that doesn't host threads sends exactly the frame it always did.
256
+ */
257
+ host?: () => Record<string, unknown> | undefined;
258
+ /**
259
+ * The repositories this harness offers Code threads (`codehost/checkouts.ts`),
260
+ * read for every frame. Only set while it hosts them; `undefined` sends none.
261
+ */
262
+ codeHostRepos?: () => string[] | undefined;
263
+ /**
264
+ * The folders this harness has opened for Code threads and its home directory
265
+ * (`codehost/folders.ts`), read for every frame as `code_host_folders` /
266
+ * `code_host_home`. Only set while it hosts them; `undefined` sends neither.
267
+ */
268
+ codeHostFolders?: () => {
269
+ folders: {
270
+ path: string;
271
+ git: boolean;
272
+ }[];
273
+ home: string;
274
+ } | undefined;
215
275
  now?: () => number;
216
276
  /** Timer seam for tests. Returns a cancel function. */
217
277
  schedule?: (fn: () => void, ms: number) => () => void;
@@ -235,6 +295,16 @@ export declare class RuntimeStatusReporter {
235
295
  private quotaObservedAt;
236
296
  private send;
237
297
  private lastSentAt;
298
+ /** The capabilities the last frame carried, and when a changed set last went out at once. */
299
+ private lastSentCapabilities;
300
+ private lastCapabilityChangeAt;
301
+ private lastCapabilityNarrowingAt;
302
+ /** The code-host fields the last frame carried, and when a changed set last went out early. */
303
+ private lastSentCodeHost;
304
+ private lastCodeHostChangeAt;
305
+ private cancelCodeHostPending;
306
+ /** The server lists {@link CODE_HOST_FOLDERS_FEATURE}: 64 KiB frames, early code-host frames. */
307
+ private codeHostServer;
238
308
  private cancelPending;
239
309
  private cancelTick;
240
310
  private refreshing;
@@ -253,6 +323,13 @@ export declare class RuntimeStatusReporter {
253
323
  setAccounts(snapshot: AccountsSnapshot | undefined, options?: {
254
324
  quiet?: boolean;
255
325
  }): void;
326
+ /**
327
+ * What the harness declares changed (the remote-terminal switch): sent at
328
+ * once, since the server lets a changed capability set through its spacing
329
+ * window and stops sending what this machine now refuses; and again in the
330
+ * next spaced slot, for a server that predates that.
331
+ */
332
+ capabilitiesChanged(): void;
256
333
  /**
257
334
  * The account choice changed (an operator picked one in ProhostAI): re-read
258
335
  * accounts and capacity on the next tick rather than in fifteen minutes.
@@ -261,11 +338,28 @@ export declare class RuntimeStatusReporter {
261
338
  /** The model to report on a run's completion, when nothing more specific is known. */
262
339
  model(): string | undefined;
263
340
  /** Start reporting on a socket that advertised the feature. */
264
- attach(send: (frame: Record<string, unknown>) => void): void;
341
+ attach(send: (frame: Record<string, unknown>) => void, server?: {
342
+ codeHostFolders?: boolean;
343
+ }): void;
344
+ /**
345
+ * What this Mac offers Code threads changed (its opened folders, say, after `folders add`). A
346
+ * server listing {@link CODE_HOST_FOLDERS_FEATURE} takes such a frame early, at most every
347
+ * {@link MIN_CODE_HOST_CHANGE_SPACING_MS}; an older one gets it in the next spaced slot.
348
+ */
349
+ codeHostChanged(): void;
350
+ private codeHostKey;
265
351
  /** Stop reporting — the socket is gone. */
266
352
  detach(): void;
267
353
  /** The frame as it would be sent now. */
268
354
  frame(): Record<string, unknown>;
355
+ /**
356
+ * `frame` with the opened folders, as many as fit (newest kept): under {@link MAX_STATUS_FRAME_BYTES},
357
+ * or {@link MAX_LARGE_STATUS_FRAME_BYTES} for a server that takes large frames.
358
+ */
359
+ private withCodeHostFolders;
360
+ private codeHostReposField;
361
+ private hostnameField;
362
+ private hostField;
269
363
  /**
270
364
  * Whether the machine's registry no longer holds exactly the accounts the
271
365
  * snapshot lists: another agent home started on a login of its own, or an
@@ -278,6 +372,8 @@ export declare class RuntimeStatusReporter {
278
372
  private refresh;
279
373
  /** Send now if spacing allows, else once it does. Coalesces. */
280
374
  private sendSoon;
375
+ private capabilitiesList;
376
+ private capabilitiesKey;
281
377
  private flush;
282
378
  }
283
379
  export {};