@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +90 -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 +230 -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/history.d.ts +80 -0
  30. package/dist/codehost/history.js +98 -0
  31. package/dist/codehost/hooks.d.ts +186 -0
  32. package/dist/codehost/hooks.js +737 -0
  33. package/dist/codehost/host.d.ts +297 -0
  34. package/dist/codehost/host.js +635 -0
  35. package/dist/codehost/host_stats.d.ts +71 -0
  36. package/dist/codehost/host_stats.js +166 -0
  37. package/dist/codehost/invocations.d.ts +12 -0
  38. package/dist/codehost/invocations.js +82 -0
  39. package/dist/codehost/ledger.d.ts +35 -0
  40. package/dist/codehost/ledger.js +91 -0
  41. package/dist/codehost/local_server.d.ts +106 -0
  42. package/dist/codehost/local_server.js +339 -0
  43. package/dist/codehost/pty.d.ts +38 -0
  44. package/dist/codehost/pty.js +47 -0
  45. package/dist/codehost/ready.d.ts +31 -0
  46. package/dist/codehost/ready.js +109 -0
  47. package/dist/codehost/relay.d.ts +175 -0
  48. package/dist/codehost/relay.js +598 -0
  49. package/dist/codehost/settings.d.ts +52 -0
  50. package/dist/codehost/settings.js +114 -0
  51. package/dist/codehost/sizing.d.ts +181 -0
  52. package/dist/codehost/sizing.js +434 -0
  53. package/dist/codehost/terminals.d.ts +421 -0
  54. package/dist/codehost/terminals.js +1027 -0
  55. package/dist/codehost/tmux.d.ts +201 -0
  56. package/dist/codehost/tmux.js +394 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +21 -0
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +4 -1
@@ -38,6 +38,34 @@ export const STATUS_INTERVAL_MS = 5 * 60_000;
38
38
  * the frame that carried a change.
39
39
  */
40
40
  export const MIN_FRAME_SPACING_MS = 31_000;
41
+ /**
42
+ * Inside that window the server still accepts a frame whose capabilities
43
+ * changed, one per 5 s (`MIN_CAPABILITY_CHANGE_INTERVAL_SECONDS`), and a strict
44
+ * narrowing (a capability dropped, nothing added) one per second
45
+ * (`MIN_CAPABILITY_NARROWING_INTERVAL_SECONDS`). A capability can be a switch
46
+ * the server enforces (`remote_terminal`). Margin on both.
47
+ */
48
+ export const MIN_CAPABILITY_CHANGE_SPACING_MS = 6_000;
49
+ export const MIN_CAPABILITY_NARROWING_SPACING_MS = 1_500;
50
+ /**
51
+ * The server drops a status frame over 8 KB (`MAX_FRAME_BYTES`). The opened
52
+ * folders are the one part of it that can grow that far, so they are cut,
53
+ * oldest first, until the frame fits under this, with margin.
54
+ */
55
+ export const MAX_STATUS_FRAME_BYTES = 8_000;
56
+ /**
57
+ * The budget once the server lists {@link CODE_HOST_FOLDERS_FEATURE} in `auth_ok.features`: it
58
+ * takes frames up to 64 KiB, so all 50 folders fit. Margin under the cap.
59
+ */
60
+ export const MAX_LARGE_STATUS_FRAME_BYTES = 63 * 1024;
61
+ /**
62
+ * A server listing this feature takes a status frame up to 64 KiB, and one whose
63
+ * `code_host_repos` / `code_host_folders` / `code_host_home` changed inside its spacing window
64
+ * (a 5 s floor) — the opened folders reach phones within seconds of `folders add`.
65
+ */
66
+ export const CODE_HOST_FOLDERS_FEATURE = 'code_host_folders';
67
+ /** The early code-host frame's spacing: the server's 5 s floor, with margin. */
68
+ export const MIN_CODE_HOST_CHANGE_SPACING_MS = 6_000;
41
69
  /** Ask the agent CLI for fresh numbers when the last ones are this old. */
42
70
  export const QUOTA_REFRESH_AFTER_MS = 15 * 60_000;
43
71
  /** Ceiling on one idle quota probe. */
@@ -117,8 +145,10 @@ export function fromClaudeRateLimitInfo(info) {
117
145
  * Windows from a `get_usage` control response (0–100, ISO strings).
118
146
  *
119
147
  * A window with a usage but a `null` reset is kept, with `resets_at: null` —
120
- * that is how an idle account's 5-hour window reads, and dropping it is what
121
- * left paired agents showing a weekly window and nothing else (B3).
148
+ * that is how an idle account's 5-hour window reads. Reporting stays complete:
149
+ * dropping it here, with the list replaced whole by each source, erased a known
150
+ * 5-hour window (B3). Whether to show an idle one is the server's call (it
151
+ * doesn't: `shared.ai_usage.windows.visible_windows`).
122
152
  *
123
153
  * Per-model weekly windows come from `rate_limits.limits[]` rows of kind
124
154
  * `weekly_scoped` (classified on `kind`, as the schema asks, never on a
@@ -160,6 +190,8 @@ export function fromClaudeGetUsage(usage) {
160
190
  const CLAUDE_PLANS = {
161
191
  pro: 'Claude Pro',
162
192
  max: 'Claude Max',
193
+ // Inferred from the others; not seen in a real `get_usage` response yet.
194
+ ultra: 'Claude Ultra',
163
195
  team: 'Claude Team',
164
196
  enterprise: 'Claude Enterprise',
165
197
  };
@@ -472,6 +504,10 @@ function asOf(window, nowMs) {
472
504
  return window;
473
505
  return { ...window, used_percent: 0, resets_at: null };
474
506
  }
507
+ /** This machine's hostname up to its first dot (`studio-001.local` is `studio-001`), or `undefined`. */
508
+ export function shortHostname(raw = os.hostname()) {
509
+ return raw.trim().split('.')[0] || undefined;
510
+ }
475
511
  /** Left out when there is nothing to declare, so an older server sees the frame it knows. */
476
512
  function capabilitiesField(capabilities) {
477
513
  return capabilities && capabilities.length > 0 ? { capabilities } : {};
@@ -498,6 +534,16 @@ export class RuntimeStatusReporter {
498
534
  quotaObservedAt;
499
535
  send;
500
536
  lastSentAt;
537
+ /** The capabilities the last frame carried, and when a changed set last went out at once. */
538
+ lastSentCapabilities;
539
+ lastCapabilityChangeAt;
540
+ lastCapabilityNarrowingAt;
541
+ /** The code-host fields the last frame carried, and when a changed set last went out early. */
542
+ lastSentCodeHost;
543
+ lastCodeHostChangeAt;
544
+ cancelCodeHostPending;
545
+ /** The server lists {@link CODE_HOST_FOLDERS_FEATURE}: 64 KiB frames, early code-host frames. */
546
+ codeHostServer = false;
501
547
  cancelPending;
502
548
  cancelTick;
503
549
  refreshing;
@@ -555,6 +601,32 @@ export class RuntimeStatusReporter {
555
601
  if (!options.quiet && JSON.stringify(before) !== JSON.stringify(snapshot))
556
602
  this.sendSoon();
557
603
  }
604
+ /**
605
+ * What the harness declares changed (the remote-terminal switch): sent at
606
+ * once, since the server lets a changed capability set through its spacing
607
+ * window and stops sending what this machine now refuses; and again in the
608
+ * next spaced slot, for a server that predates that.
609
+ */
610
+ capabilitiesChanged() {
611
+ if (!this.send)
612
+ return;
613
+ const now = this.now();
614
+ const current = this.capabilitiesList();
615
+ const previous = this.lastSentCapabilities ? JSON.parse(this.lastSentCapabilities) : [];
616
+ const changed = JSON.stringify(current) !== this.lastSentCapabilities;
617
+ // A strict narrowing (remote off: one dropped, none added) has its own short floor, as on the server.
618
+ const narrowed = current.length < previous.length && current.every((cap) => previous.includes(cap));
619
+ const since = narrowed ? this.lastCapabilityNarrowingAt : this.lastCapabilityChangeAt;
620
+ const spacing = narrowed ? MIN_CAPABILITY_NARROWING_SPACING_MS : MIN_CAPABILITY_CHANGE_SPACING_MS;
621
+ if (this.lastSentAt !== undefined && changed && (since === undefined || now - since >= spacing)) {
622
+ if (narrowed)
623
+ this.lastCapabilityNarrowingAt = now;
624
+ else
625
+ this.lastCapabilityChangeAt = now;
626
+ this.flush();
627
+ }
628
+ this.sendSoon();
629
+ }
558
630
  /**
559
631
  * The account choice changed (an operator picked one in ProhostAI): re-read
560
632
  * accounts and capacity on the next tick rather than in fifteen minutes.
@@ -573,12 +645,45 @@ export class RuntimeStatusReporter {
573
645
  return this.state.model;
574
646
  }
575
647
  /** Start reporting on a socket that advertised the feature. */
576
- attach(send) {
648
+ attach(send, server = {}) {
577
649
  this.detach();
578
650
  this.send = send;
579
651
  this.lastSentAt = undefined;
652
+ this.lastSentCodeHost = undefined;
653
+ this.lastCodeHostChangeAt = undefined;
654
+ this.codeHostServer = server.codeHostFolders === true;
580
655
  void this.tick();
581
656
  }
657
+ /**
658
+ * What this Mac offers Code threads changed (its opened folders, say, after `folders add`). A
659
+ * server listing {@link CODE_HOST_FOLDERS_FEATURE} takes such a frame early, at most every
660
+ * {@link MIN_CODE_HOST_CHANGE_SPACING_MS}; an older one gets it in the next spaced slot.
661
+ */
662
+ codeHostChanged() {
663
+ if (!this.send)
664
+ return;
665
+ if (!this.codeHostServer || this.lastSentAt === undefined) {
666
+ this.sendSoon();
667
+ return;
668
+ }
669
+ if (this.codeHostKey(this.frame()) === this.lastSentCodeHost)
670
+ return;
671
+ const wait = this.lastCodeHostChangeAt === undefined ? 0 : this.lastCodeHostChangeAt + MIN_CODE_HOST_CHANGE_SPACING_MS - this.now();
672
+ if (wait <= 0) {
673
+ this.lastCodeHostChangeAt = this.now();
674
+ this.flush();
675
+ return;
676
+ }
677
+ if (this.cancelCodeHostPending)
678
+ return;
679
+ this.cancelCodeHostPending = this.schedule(() => {
680
+ this.cancelCodeHostPending = undefined;
681
+ this.codeHostChanged();
682
+ }, wait);
683
+ }
684
+ codeHostKey(frame) {
685
+ return JSON.stringify([frame.code_host_repos, frame.code_host_folders, frame.code_host_home]);
686
+ }
582
687
  /** Stop reporting — the socket is gone. */
583
688
  detach() {
584
689
  this.send = undefined;
@@ -586,12 +691,14 @@ export class RuntimeStatusReporter {
586
691
  this.cancelPending = undefined;
587
692
  this.cancelTick?.();
588
693
  this.cancelTick = undefined;
694
+ this.cancelCodeHostPending?.();
695
+ this.cancelCodeHostPending = undefined;
589
696
  }
590
697
  /** The frame as it would be sent now. */
591
698
  frame() {
592
699
  const nowMs = this.now();
593
700
  const windows = (this.state.quota_windows ?? []).map((w) => asOf(w, nowMs));
594
- return {
701
+ const frame = {
595
702
  type: 'runtime_status',
596
703
  runtime: this.options.runtime,
597
704
  runtime_version: this.state.runtime_version ?? null,
@@ -605,7 +712,59 @@ export class RuntimeStatusReporter {
605
712
  // Never a list the registry has moved on from — the server would delete
606
713
  // what it leaves out. Left out, nothing is deleted; the next tick re-reads it.
607
714
  ...(this.accounts?.all && !this.accountsMoved() ? { machine_accounts: this.accounts.all } : {}),
715
+ ...this.hostField(),
716
+ ...this.hostnameField(),
717
+ ...this.codeHostReposField(),
608
718
  };
719
+ return this.withCodeHostFolders(frame);
720
+ }
721
+ /**
722
+ * `frame` with the opened folders, as many as fit (newest kept): under {@link MAX_STATUS_FRAME_BYTES},
723
+ * or {@link MAX_LARGE_STATUS_FRAME_BYTES} for a server that takes large frames.
724
+ */
725
+ withCodeHostFolders(frame) {
726
+ let report;
727
+ try {
728
+ report = this.options.codeHostFolders?.();
729
+ }
730
+ catch {
731
+ return frame;
732
+ }
733
+ if (!report)
734
+ return frame;
735
+ const folders = report.folders.slice(0, 50);
736
+ const withFolders = () => ({ ...frame, code_host_folders: folders, code_host_home: report.home });
737
+ const budget = this.codeHostServer ? MAX_LARGE_STATUS_FRAME_BYTES : MAX_STATUS_FRAME_BYTES;
738
+ while (folders.length > 0 && Buffer.byteLength(JSON.stringify(withFolders()), 'utf8') > budget)
739
+ folders.pop();
740
+ return withFolders();
741
+ }
742
+ codeHostReposField() {
743
+ try {
744
+ const repos = this.options.codeHostRepos?.();
745
+ return repos ? { code_host_repos: repos } : {};
746
+ }
747
+ catch {
748
+ return {};
749
+ }
750
+ }
751
+ hostnameField() {
752
+ try {
753
+ const hostname = (this.options.hostname ?? shortHostname)();
754
+ return hostname ? { hostname } : {};
755
+ }
756
+ catch {
757
+ return {};
758
+ }
759
+ }
760
+ hostField() {
761
+ try {
762
+ const host = this.options.host?.();
763
+ return host ? { host } : {};
764
+ }
765
+ catch {
766
+ return {};
767
+ }
609
768
  }
610
769
  /**
611
770
  * Whether the machine's registry no longer holds exactly the accounts the
@@ -702,12 +861,21 @@ export class RuntimeStatusReporter {
702
861
  this.flush();
703
862
  }, wait);
704
863
  }
864
+ capabilitiesList() {
865
+ return [...(this.options.capabilities?.() ?? [])].sort();
866
+ }
867
+ capabilitiesKey() {
868
+ return JSON.stringify(this.capabilitiesList());
869
+ }
705
870
  flush() {
706
871
  if (!this.send)
707
872
  return;
708
873
  try {
709
- this.send(this.frame());
874
+ const frame = this.frame();
875
+ this.send(frame);
710
876
  this.lastSentAt = this.now();
877
+ this.lastSentCapabilities = this.capabilitiesKey();
878
+ this.lastSentCodeHost = this.codeHostKey(frame);
711
879
  }
712
880
  catch (err) {
713
881
  this.options.log?.(`runtime status not sent: ${err instanceof Error ? err.message : String(err)}`);
@@ -38,6 +38,8 @@ export interface RunCheckout {
38
38
  path: string;
39
39
  /** The operator's checkout it was made from — the one the agent must leave alone. */
40
40
  source: string;
41
+ /** Made by this call (`git worktree add`), not an existing one kept from an earlier run. */
42
+ created?: boolean;
41
43
  }
42
44
  export interface CheckoutFailure {
43
45
  source: string;
@@ -180,8 +180,9 @@ export class WorktreeManager {
180
180
  const name = path.basename(source);
181
181
  const target = path.join(dir, name);
182
182
  let failure;
183
+ const existed = isCheckout(target);
183
184
  try {
184
- failure = isCheckout(target) ? await this.reuse(source, target) : await this.add(source, target);
185
+ failure = existed ? await this.reuse(source, target) : await this.add(source, target);
185
186
  }
186
187
  catch (err) {
187
188
  failure = err instanceof Error ? err.message : String(err);
@@ -189,7 +190,7 @@ export class WorktreeManager {
189
190
  if (failure)
190
191
  prepared.failures.push({ source, reason: failure });
191
192
  else
192
- prepared.checkouts.push({ name, path: target, source });
193
+ prepared.checkouts.push({ name, path: target, source, created: !existed });
193
194
  }
194
195
  // Housekeeping for other conversations must not cost this one its run.
195
196
  await this.pruneIfDue(dir).catch((err) => {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Whether an agent this Mac hosts is mid-turn, for the desktop app's "Keep this
3
+ * Mac awake while an agent is working".
4
+ *
5
+ * Two layers, both under `$PROHOST_HOME/code-host/`:
6
+ *
7
+ * - `turns/<terminalId>` — one marker per terminal whose agent is working: made
8
+ * when a turn starts (Claude Code's `UserPromptSubmit` hook; a goal the host
9
+ * starts an agent with; a reply it types), removed when the turn ends (Claude
10
+ * Code's `Stop` hook, Codex's `agent-turn-complete`) or is interrupted from an
11
+ * attached viewer (Esc / Ctrl+C). The hooks run in their own processes, so
12
+ * the marker is a file, not daemon state.
13
+ * - `activity.json` — what the desktop app reads (0600, written atomically):
14
+ *
15
+ * { "v": 1, "busy": true, "updatedAt": "<ISO>" }
16
+ *
17
+ * The daemon writes it on its settings poll: when `busy` changes, and at
18
+ * least every {@link ACTIVITY_REFRESH_MS} either way. It is removed when the
19
+ * host stops or hosting is turned off. **A reader treats an `updatedAt`
20
+ * older than {@link ACTIVITY_STALE_MS} as not busy**: a daemon that died
21
+ * never keeps the Mac awake.
22
+ *
23
+ * A marker counts only for a terminal the host still has, and only for
24
+ * {@link MAX_TURN_MS}: an agent that exited mid-turn without its end hook (a
25
+ * crash, two Ctrl+C typed straight into tmux) never holds the Mac awake for
26
+ * good.
27
+ */
28
+ export declare const ACTIVITY_VERSION = 1;
29
+ /** The daemon rewrites `activity.json` at least this often, busy or not. */
30
+ export declare const ACTIVITY_REFRESH_MS = 30000;
31
+ /** A reader treats an `updatedAt` older than this as not busy (documented for the desktop app). */
32
+ export declare const ACTIVITY_STALE_MS: number;
33
+ /** A turn marker older than this no longer counts as working. */
34
+ export declare const MAX_TURN_MS: number;
35
+ export interface Activity {
36
+ v: 1;
37
+ busy: boolean;
38
+ updatedAt: string;
39
+ }
40
+ export declare function activityPath(env?: NodeJS.ProcessEnv): string;
41
+ /**
42
+ * Mark `terminalId`'s agent as mid-turn (`working`) or not. Never throws: a
43
+ * marker that can't be written only costs keep-awake, never a hook or a reply.
44
+ */
45
+ export declare function markTurn(terminalId: string | undefined, working: boolean, env?: NodeJS.ProcessEnv): void;
46
+ /**
47
+ * Whether any of `live` (the terminals the host has) is mid-turn now. A marker
48
+ * of a terminal not in `live` doesn't count (its window may be gone, or the
49
+ * host's list only momentarily empty) and is kept; only markers past
50
+ * {@link MAX_TURN_MS} are removed. Never throws.
51
+ */
52
+ export declare function anyTurnWorking(live: Iterable<string>, env?: NodeJS.ProcessEnv, now?: number): boolean;
53
+ /** The daemon's side: writes `activity.json` on its poll, and removes it when hosting stops. */
54
+ export declare class ActivityReporter {
55
+ private readonly env;
56
+ private readonly now;
57
+ private lastBusy;
58
+ private lastWriteAt;
59
+ constructor(env: NodeJS.ProcessEnv, now?: () => number);
60
+ /** Re-read the markers for `live` and write the file when it changed or is due. Never throws. */
61
+ tick(live: Iterable<string>): void;
62
+ /** Hosting stopped: nothing says this Mac is busy any more. */
63
+ clear(): void;
64
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Whether an agent this Mac hosts is mid-turn, for the desktop app's "Keep this
3
+ * Mac awake while an agent is working".
4
+ *
5
+ * Two layers, both under `$PROHOST_HOME/code-host/`:
6
+ *
7
+ * - `turns/<terminalId>` — one marker per terminal whose agent is working: made
8
+ * when a turn starts (Claude Code's `UserPromptSubmit` hook; a goal the host
9
+ * starts an agent with; a reply it types), removed when the turn ends (Claude
10
+ * Code's `Stop` hook, Codex's `agent-turn-complete`) or is interrupted from an
11
+ * attached viewer (Esc / Ctrl+C). The hooks run in their own processes, so
12
+ * the marker is a file, not daemon state.
13
+ * - `activity.json` — what the desktop app reads (0600, written atomically):
14
+ *
15
+ * { "v": 1, "busy": true, "updatedAt": "<ISO>" }
16
+ *
17
+ * The daemon writes it on its settings poll: when `busy` changes, and at
18
+ * least every {@link ACTIVITY_REFRESH_MS} either way. It is removed when the
19
+ * host stops or hosting is turned off. **A reader treats an `updatedAt`
20
+ * older than {@link ACTIVITY_STALE_MS} as not busy**: a daemon that died
21
+ * never keeps the Mac awake.
22
+ *
23
+ * A marker counts only for a terminal the host still has, and only for
24
+ * {@link MAX_TURN_MS}: an agent that exited mid-turn without its end hook (a
25
+ * crash, two Ctrl+C typed straight into tmux) never holds the Mac awake for
26
+ * good.
27
+ */
28
+ import { readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { codeHostDir, ensurePrivateDir, writePrivateJson } from './settings.js';
31
+ import { isValidId } from './tmux.js';
32
+ export const ACTIVITY_VERSION = 1;
33
+ /** The daemon rewrites `activity.json` at least this often, busy or not. */
34
+ export const ACTIVITY_REFRESH_MS = 30_000;
35
+ /** A reader treats an `updatedAt` older than this as not busy (documented for the desktop app). */
36
+ export const ACTIVITY_STALE_MS = 3 * 60_000;
37
+ /** A turn marker older than this no longer counts as working. */
38
+ export const MAX_TURN_MS = 6 * 3_600_000;
39
+ export function activityPath(env = process.env) {
40
+ return path.join(codeHostDir(env), 'activity.json');
41
+ }
42
+ function turnsDir(env) {
43
+ return path.join(codeHostDir(env), 'turns');
44
+ }
45
+ /**
46
+ * Mark `terminalId`'s agent as mid-turn (`working`) or not. Never throws: a
47
+ * marker that can't be written only costs keep-awake, never a hook or a reply.
48
+ */
49
+ export function markTurn(terminalId, working, env = process.env) {
50
+ // Only a valid id names a path: nothing from a hook's environment walks out of the dir.
51
+ if (!isValidId(terminalId))
52
+ return;
53
+ const file = path.join(turnsDir(env), terminalId);
54
+ try {
55
+ if (working) {
56
+ ensurePrivateDir(turnsDir(env));
57
+ writeFileSync(file, '', { mode: 0o600 });
58
+ }
59
+ else {
60
+ rmSync(file, { force: true });
61
+ }
62
+ }
63
+ catch {
64
+ /* keep-awake only */
65
+ }
66
+ }
67
+ /**
68
+ * Whether any of `live` (the terminals the host has) is mid-turn now. A marker
69
+ * of a terminal not in `live` doesn't count (its window may be gone, or the
70
+ * host's list only momentarily empty) and is kept; only markers past
71
+ * {@link MAX_TURN_MS} are removed. Never throws.
72
+ */
73
+ export function anyTurnWorking(live, env = process.env, now = Date.now()) {
74
+ const known = new Set(live);
75
+ let names;
76
+ try {
77
+ names = readdirSync(turnsDir(env));
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ let working = false;
83
+ for (const name of names) {
84
+ const file = path.join(turnsDir(env), name);
85
+ try {
86
+ const fresh = now - statSync(file).mtimeMs < MAX_TURN_MS;
87
+ if (!fresh)
88
+ rmSync(file, { force: true });
89
+ else if (known.has(name))
90
+ working = true;
91
+ }
92
+ catch {
93
+ /* removed meanwhile (its turn ended), or not ours to remove: never fails the poll */
94
+ }
95
+ }
96
+ return working;
97
+ }
98
+ /** The daemon's side: writes `activity.json` on its poll, and removes it when hosting stops. */
99
+ export class ActivityReporter {
100
+ env;
101
+ now;
102
+ lastBusy;
103
+ lastWriteAt = 0;
104
+ constructor(env, now = Date.now) {
105
+ this.env = env;
106
+ this.now = now;
107
+ }
108
+ /** Re-read the markers for `live` and write the file when it changed or is due. Never throws. */
109
+ tick(live) {
110
+ const now = this.now();
111
+ const busy = anyTurnWorking(live, this.env, now);
112
+ if (busy === this.lastBusy && now - this.lastWriteAt < ACTIVITY_REFRESH_MS)
113
+ return;
114
+ try {
115
+ const activity = { v: ACTIVITY_VERSION, busy, updatedAt: new Date(now).toISOString() };
116
+ writePrivateJson(activityPath(this.env), activity);
117
+ this.lastBusy = busy;
118
+ this.lastWriteAt = now;
119
+ }
120
+ catch {
121
+ /* tried again on the next poll */
122
+ }
123
+ }
124
+ /** Hosting stopped: nothing says this Mac is busy any more. */
125
+ clear() {
126
+ this.lastBusy = undefined;
127
+ this.lastWriteAt = 0;
128
+ try {
129
+ rmSync(activityPath(this.env), { force: true });
130
+ }
131
+ catch {
132
+ /* a stale file reads as not busy once it ages (ACTIVITY_STALE_MS) */
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which coding agents this Mac can run in a Code thread: the ones whose binary resolves on the
3
+ * daemon's `PATH` (what its tmux server, and so every thread window, inherits).
4
+ *
5
+ * Declared on every status frame as `code_thread_agent_<agent>` beside `code_threads_v2`, so the
6
+ * server offers a phone or the web only the agents that will start here, whatever runtime the
7
+ * pairing itself reports (a `--host-only` daemon reports `custom`). Re-checked per frame: a few
8
+ * `stat`s over `PATH`, so installing Codex shows up within one status interval.
9
+ */
10
+ export type InstalledAgent = 'claude_code' | 'codex';
11
+ /** The command a thread window runs for each agent (see `agentCommandLine`). */
12
+ export declare const AGENT_BINARIES: Readonly<Record<InstalledAgent, string>>;
13
+ /**
14
+ * `code_thread_agent_<agent>`. Underscores, not a colon: the server takes a capability only as
15
+ * `^[a-z0-9_]+$` (`shared/agents/runtime_status.py`), and one it can't read rejects the whole frame.
16
+ */
17
+ export declare const CODE_THREAD_AGENT_CAPABILITY_PREFIX = "code_thread_agent_";
18
+ /**
19
+ * Declared beside the per-agent capabilities whatever they are: "this CLI says which agents it
20
+ * runs", so none declared with it means none installed, not an older CLI that says nothing.
21
+ */
22
+ export declare const CODE_THREAD_AGENTS_CAPABILITY = "code_thread_agents";
23
+ export type IsExecutable = (file: string) => boolean;
24
+ /** Whether `binary` resolves to an executable file in one of `env.PATH`'s directories. */
25
+ export declare function resolvesOnPath(binary: string, env?: NodeJS.ProcessEnv, isExecutable?: IsExecutable): boolean;
26
+ /** The agents whose binary resolves on `env.PATH`, in the order clients list them. */
27
+ export declare function installedAgents(env?: NodeJS.ProcessEnv, isExecutable?: IsExecutable): InstalledAgent[];
28
+ /** {@link CODE_THREAD_AGENTS_CAPABILITY}, then `code_thread_agent_<agent>` for each of `agents`. */
29
+ export declare function agentCapabilities(agents: readonly InstalledAgent[]): string[];
30
+ /** Why a start for `agent` is refused on a Mac without its binary: the thread shows it as the failure. */
31
+ export declare function missingAgentReason(agent: InstalledAgent): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which coding agents this Mac can run in a Code thread: the ones whose binary resolves on the
3
+ * daemon's `PATH` (what its tmux server, and so every thread window, inherits).
4
+ *
5
+ * Declared on every status frame as `code_thread_agent_<agent>` beside `code_threads_v2`, so the
6
+ * server offers a phone or the web only the agents that will start here, whatever runtime the
7
+ * pairing itself reports (a `--host-only` daemon reports `custom`). Re-checked per frame: a few
8
+ * `stat`s over `PATH`, so installing Codex shows up within one status interval.
9
+ */
10
+ import { accessSync, constants, statSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ /** The command a thread window runs for each agent (see `agentCommandLine`). */
13
+ export const AGENT_BINARIES = { claude_code: 'claude', codex: 'codex' };
14
+ /**
15
+ * `code_thread_agent_<agent>`. Underscores, not a colon: the server takes a capability only as
16
+ * `^[a-z0-9_]+$` (`shared/agents/runtime_status.py`), and one it can't read rejects the whole frame.
17
+ */
18
+ export const CODE_THREAD_AGENT_CAPABILITY_PREFIX = 'code_thread_agent_';
19
+ /**
20
+ * Declared beside the per-agent capabilities whatever they are: "this CLI says which agents it
21
+ * runs", so none declared with it means none installed, not an older CLI that says nothing.
22
+ */
23
+ export const CODE_THREAD_AGENTS_CAPABILITY = 'code_thread_agents';
24
+ const AGENT_LABELS = { claude_code: 'Claude Code', codex: 'Codex' };
25
+ function defaultIsExecutable(file) {
26
+ try {
27
+ if (!statSync(file).isFile())
28
+ return false;
29
+ accessSync(file, constants.X_OK);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /** Whether `binary` resolves to an executable file in one of `env.PATH`'s directories. */
37
+ export function resolvesOnPath(binary, env = process.env, isExecutable = defaultIsExecutable) {
38
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
39
+ // An empty entry is the current directory, which is no place a daemon finds an agent.
40
+ if (dir && path.isAbsolute(dir) && isExecutable(path.join(dir, binary)))
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ /** The agents whose binary resolves on `env.PATH`, in the order clients list them. */
46
+ export function installedAgents(env = process.env, isExecutable = defaultIsExecutable) {
47
+ return Object.keys(AGENT_BINARIES).filter((agent) => resolvesOnPath(AGENT_BINARIES[agent], env, isExecutable));
48
+ }
49
+ /** {@link CODE_THREAD_AGENTS_CAPABILITY}, then `code_thread_agent_<agent>` for each of `agents`. */
50
+ export function agentCapabilities(agents) {
51
+ return [CODE_THREAD_AGENTS_CAPABILITY, ...agents.map((agent) => `${CODE_THREAD_AGENT_CAPABILITY_PREFIX}${agent}`)];
52
+ }
53
+ /** Why a start for `agent` is refused on a Mac without its binary: the thread shows it as the failure. */
54
+ export function missingAgentReason(agent) {
55
+ return (`${AGENT_LABELS[agent]} isn't installed on this Mac (no \`${AGENT_BINARIES[agent]}\` on the PATH Prohost runs with). ` +
56
+ 'Install it there, or start the thread with another agent.');
57
+ }