@prohost/cli 0.13.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +129 -0
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +230 -9
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +186 -0
- package/dist/codehost/hooks.js +737 -0
- package/dist/codehost/host.d.ts +297 -0
- package/dist/codehost/host.js +635 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +175 -0
- package/dist/codehost/relay.js +598 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +181 -0
- package/dist/codehost/sizing.js +434 -0
- package/dist/codehost/terminals.d.ts +421 -0
- package/dist/codehost/terminals.js +1027 -0
- package/dist/codehost/tmux.d.ts +201 -0
- package/dist/codehost/tmux.js +394 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -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
|
|
121
|
-
*
|
|
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
|
-
|
|
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.
|
|
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;
|
package/dist/agent/worktrees.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|