@prohost/cli 0.14.0 → 0.16.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 (69) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `prohost host pair` — add this computer to a workspace.
3
+ *
4
+ * Two ways in:
5
+ *
6
+ * - **Device code** (default). The CLI asks for a code, prints it with the
7
+ * page to enter it on, and polls until someone in the workspace approves it
8
+ * (`/v1/computers/pairing/start` then `/poll`). Nothing secret is printed:
9
+ * the code is only good for the person who approves it, for 15 minutes.
10
+ * - **Redeem token** (`--token`). The signed-in desktop app asks the server
11
+ * for a one-time token and hands it here (`/v1/computers/pairing/redeem`).
12
+ * The token is a secret and is never printed.
13
+ *
14
+ * Either way the answer is the computer credential, stored in
15
+ * `$PROHOST_HOME/host/computer.json` (0600). `--expect-account` refuses — and
16
+ * does not store — a credential for any other workspace.
17
+ *
18
+ * A program that runs this reads `$PROHOST_PAIR_RESULT_FILE` (the same
19
+ * variable `agent pair` writes), never the output: `{version: 1, outcome,
20
+ * computer_id, account: {id, name}, message}`.
21
+ */
22
+ import { chmodSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { wsUrlFromBaseUrl } from '../agent/credentials.js';
25
+ import { PAIR_RESULT_FILE_ENV } from '../agent/pair_result.js';
26
+ import { PAIRING_POLL_PATH, PAIRING_REDEEM_PATH, PAIRING_START_PATH, callJson, revokeComputer } from './api.js';
27
+ import { machineFacts, saveComputer } from './computer.js';
28
+ export const HOST_PAIR_OUTCOMES = [
29
+ 'paired',
30
+ /** The device code ran out before anyone approved it. */
31
+ 'expired',
32
+ /** The redeem token was refused: invalid, used, expired, or for another person. */
33
+ 'token_rejected',
34
+ /** Approved, but not in the workspace `--expect-account` named. Nothing was saved. */
35
+ 'workspace_unconfirmed',
36
+ /** Approved, and the person answered no to "Add this computer to <workspace>?". Revoked, nothing saved. */
37
+ 'declined',
38
+ 'rate_limited',
39
+ 'unreachable',
40
+ 'invalid_arguments',
41
+ 'failed',
42
+ ];
43
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+ /** Poll no faster than this, whatever the server says. */
45
+ const MIN_POLL_MS = 2_000;
46
+ /** Give up this long after `expires_in` even if the server never says 410. */
47
+ const EXPIRY_GRACE_MS = 30_000;
48
+ function result(outcome, message, computerId = null, account = null) {
49
+ return { version: 1, outcome, computer_id: computerId, account, message };
50
+ }
51
+ /** Write the result file, atomically and 0600, when `$PROHOST_PAIR_RESULT_FILE` asks for one. */
52
+ export function writeHostPairResult(value, env = process.env, warn = (line) => process.stderr.write(`${line}\n`)) {
53
+ const target = env[PAIR_RESULT_FILE_ENV];
54
+ if (target === undefined)
55
+ return;
56
+ if (!path.isAbsolute(target)) {
57
+ warn(`! $${PAIR_RESULT_FILE_ENV} must be an absolute path; no pairing result was written.`);
58
+ return;
59
+ }
60
+ const temp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.tmp`);
61
+ try {
62
+ writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' });
63
+ chmodSync(temp, 0o600);
64
+ renameSync(temp, target);
65
+ }
66
+ catch (err) {
67
+ rmSync(temp, { force: true });
68
+ warn(`! Could not write the pairing result: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ }
71
+ function accountFrom(value) {
72
+ const account = value;
73
+ if (!account || typeof account !== 'object' || typeof account.id !== 'string' || !UUID.test(account.id))
74
+ return null;
75
+ return { id: account.id, name: typeof account.name === 'string' ? account.name : null };
76
+ }
77
+ /** Turn a 200 pairing answer into what is stored, or say what is missing. */
78
+ export function credentialsFrom(body, baseUrl, facts) {
79
+ if (!body)
80
+ return 'the answer was not JSON';
81
+ const account = accountFrom(body.account);
82
+ if (typeof body.computer_id !== 'string' || !body.computer_id)
83
+ return 'the answer named no computer';
84
+ if (typeof body.api_key !== 'string' || !body.api_key)
85
+ return 'the answer carried no computer key';
86
+ if (!account)
87
+ return 'the answer named no workspace';
88
+ const wsUrl = typeof body.ws_url === 'string' && body.ws_url ? body.ws_url : wsUrlFromBaseUrl(baseUrl);
89
+ if (!wsUrl)
90
+ return 'the answer carried no event stream address';
91
+ return {
92
+ version: 1,
93
+ computer_id: body.computer_id,
94
+ account,
95
+ api_key: body.api_key,
96
+ webhook_secret: typeof body.webhook_secret === 'string' ? body.webhook_secret : null,
97
+ ...(typeof body.subscription_id === 'string' ? { subscription_id: body.subscription_id } : {}),
98
+ ws_url: wsUrl,
99
+ mcp_url: typeof body.mcp_url === 'string' ? body.mcp_url : '',
100
+ base_url: baseUrl,
101
+ machine_id: facts.machine_id,
102
+ name: facts.name,
103
+ paired_at: new Date().toISOString(),
104
+ };
105
+ }
106
+ function defaultSleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ /**
110
+ * Pair this computer. Never throws for an outcome; the result says what
111
+ * happened and is what the command prints and writes to the result file.
112
+ */
113
+ export async function runHostPair(options) {
114
+ const env = options.env ?? process.env;
115
+ const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
116
+ const sleep = options.sleep ?? defaultSleep;
117
+ const now = options.now ?? Date.now;
118
+ const api = { baseUrl: options.baseUrl, fetchImpl: options.fetchImpl };
119
+ if (options.expectAccount !== undefined && !UUID.test(options.expectAccount)) {
120
+ return result('invalid_arguments', '--expect-account must be a workspace id (a UUID).');
121
+ }
122
+ if (options.token !== undefined && !options.token.trim()) {
123
+ return result('invalid_arguments', '--token needs a value.');
124
+ }
125
+ let facts;
126
+ try {
127
+ facts = options.facts ?? machineFacts({ env, name: options.name });
128
+ }
129
+ catch (err) {
130
+ return result('failed', err instanceof Error ? err.message : String(err));
131
+ }
132
+ const finish = async (body) => {
133
+ const stored = credentialsFrom(body, options.baseUrl, facts);
134
+ if (typeof stored === 'string')
135
+ return result('failed', `Pairing answered, but ${stored}. Nothing was saved.`);
136
+ const expected = options.expectAccount?.toLowerCase();
137
+ /** Revoke the key that won't be kept; the words say whether that landed. */
138
+ const discard = async () => {
139
+ const revoked = await revokeComputer({ baseUrl: options.baseUrl, apiKey: stored.api_key, fetchImpl: options.fetchImpl }, stored.computer_id).catch(() => ({ result: 'failed' }));
140
+ return revoked.result === 'failed'
141
+ ? `its key could not be revoked, so it may still be listed in ${stored.account.name ?? 'that workspace'}: remove it there`
142
+ : 'its key was revoked';
143
+ };
144
+ if (expected && stored.account.id.toLowerCase() !== expected) {
145
+ // Not kept, so not left working either.
146
+ const revoked = await discard();
147
+ return result('workspace_unconfirmed', `This computer was approved in another workspace than the one expected (${expected}). Nothing was saved, ` +
148
+ `and ${revoked}.`, stored.computer_id, stored.account);
149
+ }
150
+ log(`Approved in workspace: ${stored.account.name ?? stored.account.id}`);
151
+ if (!expected && options.confirm && !(await options.confirm(stored.account))) {
152
+ const revoked = await discard();
153
+ return result('declined', `Not added: nothing was saved, and ${revoked}.`, stored.computer_id, stored.account);
154
+ }
155
+ saveComputer(stored, env);
156
+ const where = stored.account.name ? ` to ${stored.account.name}` : '';
157
+ return result('paired', `Added "${stored.name}"${where}.`, stored.computer_id, stored.account);
158
+ };
159
+ if (options.token !== undefined) {
160
+ const response = await callJson(api, PAIRING_REDEEM_PATH, { redeem_token: options.token.trim(), ...facts });
161
+ if (response.status === 200 || response.status === 201)
162
+ return await finish(response.body);
163
+ if (response.status === 0)
164
+ return result('unreachable', `Could not reach ProhostAI: ${response.error ?? 'no answer'}.`);
165
+ if (response.status === 429)
166
+ return result('rate_limited', 'Too many attempts; wait a minute and try again.');
167
+ if ([400, 401, 403, 404, 410].includes(response.status)) {
168
+ return result('token_rejected', 'That sign-in token was refused: it expired, was already used, or is not yours.');
169
+ }
170
+ return result('failed', `Pairing failed (HTTP ${response.status}).`);
171
+ }
172
+ const started = await callJson(api, PAIRING_START_PATH, facts);
173
+ if (started.status === 0)
174
+ return result('unreachable', `Could not reach ProhostAI: ${started.error ?? 'no answer'}.`);
175
+ if (started.status === 429)
176
+ return result('rate_limited', 'Too many attempts; wait a minute and try again.');
177
+ const start = started.body;
178
+ if ((started.status !== 200 && started.status !== 201) ||
179
+ !start ||
180
+ typeof start.device_code !== 'string' ||
181
+ typeof start.user_code !== 'string') {
182
+ return result('failed', `Could not start pairing (HTTP ${started.status}).`);
183
+ }
184
+ const verificationUrl = typeof start.verification_url === 'string' ? start.verification_url : undefined;
185
+ const expiresInMs = (typeof start.expires_in === 'number' ? start.expires_in : 900) * 1000;
186
+ let intervalMs = Math.max(MIN_POLL_MS, (typeof start.interval === 'number' ? start.interval : 5) * 1000);
187
+ log(`To add "${facts.name}", open ${verificationUrl ?? 'ProhostAI → AI Employees → Computers → Add a computer'}`);
188
+ log(`and enter this code: ${start.user_code}`);
189
+ log(`(The code works for ${Math.round(expiresInMs / 60_000)} minutes. Waiting for approval…)`);
190
+ const deadline = now() + expiresInMs + EXPIRY_GRACE_MS;
191
+ let unreachableSince;
192
+ while (now() < deadline) {
193
+ if (options.signal?.aborted)
194
+ return result('failed', 'Stopped before the code was approved.');
195
+ await sleep(intervalMs);
196
+ const polled = await callJson(api, PAIRING_POLL_PATH, { device_code: start.device_code });
197
+ if (polled.status === 200 || polled.status === 201)
198
+ return await finish(polled.body);
199
+ if (polled.status === 428 || (polled.status === 400 && polled.body?.status === 'pending')) {
200
+ unreachableSince = undefined;
201
+ continue;
202
+ }
203
+ if (polled.status === 410)
204
+ return result('expired', 'The code expired before it was approved. Run this again for a new one.');
205
+ if (polled.status === 429) {
206
+ intervalMs += 5_000;
207
+ continue;
208
+ }
209
+ if (polled.status === 0 || polled.status >= 500) {
210
+ // A wifi blip is not a failed pairing: keep asking until the code expires.
211
+ unreachableSince ??= now();
212
+ continue;
213
+ }
214
+ return result('failed', `Pairing failed (HTTP ${polled.status}).`);
215
+ }
216
+ return unreachableSince !== undefined
217
+ ? result('unreachable', 'Could not reach ProhostAI before the code expired.')
218
+ : result('expired', 'The code expired before it was approved. Run this again for a new one.');
219
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Each run's peak memory: the resident memory of its agent process and
3
+ * everything that process started, sampled while it runs.
4
+ *
5
+ * One process listing per sample covers every run: `ps -A -o pid=,ppid=,rss=`
6
+ * on macOS and Linux (RSS in KiB), `Win32_Process` through PowerShell on
7
+ * Windows (working set in bytes). A sample that can't be taken is skipped —
8
+ * the peak is a lower bound, never invented.
9
+ */
10
+ export interface ProcessRow {
11
+ pid: number;
12
+ ppid: number;
13
+ /** Resident memory in bytes. */
14
+ rssBytes: number;
15
+ }
16
+ /** `ps -A -o pid=,ppid=,rss=` → rows (RSS reported in KiB). */
17
+ export declare function parsePs(text: string): ProcessRow[];
18
+ /** `"<pid>,<ppid>,<bytes>"` lines from the PowerShell listing. */
19
+ export declare function parseWindowsProcesses(text: string): ProcessRow[];
20
+ /** Memory of `root` and all its descendants in one listing, in bytes. */
21
+ export declare function treeBytes(rows: readonly ProcessRow[], root: number): number;
22
+ export type ProcessLister = () => Promise<ProcessRow[] | undefined>;
23
+ export declare function defaultProcessLister(platform?: NodeJS.Platform): ProcessLister;
24
+ /** Default sampling interval. */
25
+ export declare const PEAK_SAMPLE_MS = 5000;
26
+ /** Tracks each run's root process and keeps its peak. */
27
+ export declare class PeakMemory {
28
+ private readonly list;
29
+ private readonly intervalMs;
30
+ private readonly roots;
31
+ private readonly peaks;
32
+ private timer;
33
+ private sampling;
34
+ constructor(list?: ProcessLister, intervalMs?: number);
35
+ /** A process started for `runId` (a run may start more than one: a retry). */
36
+ addProcess(runId: string, pid: number | undefined): void;
37
+ /** The run's peak so far, in MB (rounded up); `undefined` before any sample. */
38
+ peakMb(runId: string): number | undefined;
39
+ /** Stop tracking a run; returns its peak in MB. */
40
+ finish(runId: string): number | undefined;
41
+ sample(): Promise<void>;
42
+ stop(): void;
43
+ private start;
44
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Each run's peak memory: the resident memory of its agent process and
3
+ * everything that process started, sampled while it runs.
4
+ *
5
+ * One process listing per sample covers every run: `ps -A -o pid=,ppid=,rss=`
6
+ * on macOS and Linux (RSS in KiB), `Win32_Process` through PowerShell on
7
+ * Windows (working set in bytes). A sample that can't be taken is skipped —
8
+ * the peak is a lower bound, never invented.
9
+ */
10
+ import { execFile } from 'node:child_process';
11
+ /** `ps -A -o pid=,ppid=,rss=` → rows (RSS reported in KiB). */
12
+ export function parsePs(text) {
13
+ return text.split('\n').flatMap((line) => {
14
+ const parts = line.trim().split(/\s+/);
15
+ if (parts.length < 3)
16
+ return [];
17
+ const [pid, ppid, rss] = parts.map((p) => Number.parseInt(p, 10));
18
+ return Number.isFinite(pid) && Number.isFinite(ppid) && Number.isFinite(rss)
19
+ ? [{ pid: pid, ppid: ppid, rssBytes: rss * 1024 }]
20
+ : [];
21
+ });
22
+ }
23
+ /** `"<pid>,<ppid>,<bytes>"` lines from the PowerShell listing. */
24
+ export function parseWindowsProcesses(text) {
25
+ return text.split(/\r?\n/).flatMap((line) => {
26
+ const parts = line.trim().replace(/"/g, '').split(',');
27
+ if (parts.length < 3)
28
+ return [];
29
+ const [pid, ppid, bytes] = parts.map((p) => Number.parseInt(p, 10));
30
+ return Number.isFinite(pid) && Number.isFinite(ppid) && Number.isFinite(bytes)
31
+ ? [{ pid: pid, ppid: ppid, rssBytes: bytes }]
32
+ : [];
33
+ });
34
+ }
35
+ /** Memory of `root` and all its descendants in one listing, in bytes. */
36
+ export function treeBytes(rows, root) {
37
+ const children = new Map();
38
+ for (const row of rows) {
39
+ const list = children.get(row.ppid) ?? [];
40
+ list.push(row);
41
+ children.set(row.ppid, list);
42
+ }
43
+ const self = rows.find((row) => row.pid === root);
44
+ if (!self)
45
+ return 0;
46
+ let total = 0;
47
+ const stack = [self];
48
+ const seen = new Set();
49
+ while (stack.length > 0) {
50
+ const row = stack.pop();
51
+ if (seen.has(row.pid))
52
+ continue;
53
+ seen.add(row.pid);
54
+ total += row.rssBytes;
55
+ for (const child of children.get(row.pid) ?? [])
56
+ if (child.pid !== row.pid)
57
+ stack.push(child);
58
+ }
59
+ return total;
60
+ }
61
+ const WINDOWS_SCRIPT = 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId),$($_.ParentProcessId),$($_.WorkingSetSize)" }';
62
+ export function defaultProcessLister(platform = process.platform) {
63
+ return () => new Promise((resolve) => {
64
+ const [file, args, parse] = platform === 'win32'
65
+ ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_SCRIPT], parseWindowsProcesses]
66
+ : ['ps', ['-A', '-o', 'pid=,ppid=,rss='], parsePs];
67
+ execFile(file, [...args], { timeout: 5_000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
68
+ resolve(error ? undefined : parse(String(stdout)));
69
+ });
70
+ });
71
+ }
72
+ /** Default sampling interval. */
73
+ export const PEAK_SAMPLE_MS = 5_000;
74
+ /** Tracks each run's root process and keeps its peak. */
75
+ export class PeakMemory {
76
+ list;
77
+ intervalMs;
78
+ roots = new Map();
79
+ peaks = new Map();
80
+ timer;
81
+ sampling = false;
82
+ constructor(list = defaultProcessLister(), intervalMs = PEAK_SAMPLE_MS) {
83
+ this.list = list;
84
+ this.intervalMs = intervalMs;
85
+ }
86
+ /** A process started for `runId` (a run may start more than one: a retry). */
87
+ addProcess(runId, pid) {
88
+ if (!pid)
89
+ return;
90
+ const roots = this.roots.get(runId) ?? [];
91
+ roots.push(pid);
92
+ this.roots.set(runId, roots);
93
+ this.start();
94
+ void this.sample();
95
+ }
96
+ /** The run's peak so far, in MB (rounded up); `undefined` before any sample. */
97
+ peakMb(runId) {
98
+ const bytes = this.peaks.get(runId);
99
+ return bytes === undefined ? undefined : Math.ceil(bytes / (1024 * 1024));
100
+ }
101
+ /** Stop tracking a run; returns its peak in MB. */
102
+ finish(runId) {
103
+ const peak = this.peakMb(runId);
104
+ this.roots.delete(runId);
105
+ this.peaks.delete(runId);
106
+ if (this.roots.size === 0)
107
+ this.stop();
108
+ return peak;
109
+ }
110
+ async sample() {
111
+ if (this.sampling || this.roots.size === 0)
112
+ return;
113
+ this.sampling = true;
114
+ try {
115
+ const rows = await this.list();
116
+ if (!rows)
117
+ return;
118
+ for (const [runId, roots] of this.roots) {
119
+ const bytes = roots.reduce((sum, root) => sum + treeBytes(rows, root), 0);
120
+ if (bytes > (this.peaks.get(runId) ?? 0))
121
+ this.peaks.set(runId, bytes);
122
+ }
123
+ }
124
+ finally {
125
+ this.sampling = false;
126
+ }
127
+ }
128
+ stop() {
129
+ if (this.timer)
130
+ clearInterval(this.timer);
131
+ this.timer = undefined;
132
+ }
133
+ start() {
134
+ if (this.timer)
135
+ return;
136
+ this.timer = setInterval(() => void this.sample(), this.intervalMs);
137
+ this.timer.unref?.();
138
+ }
139
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Which waiting run starts next on this computer.
3
+ *
4
+ * One computer serves many employees, so "oldest first" alone would let one
5
+ * busy employee crowd out the rest. The order is:
6
+ *
7
+ * 1. **Someone waiting** — a mention, a DM, a customer's message — before
8
+ * background work (a trigger, a scheduled job).
9
+ * 2. **Take turns between employees** — the employee with the fewest runs going,
10
+ * then the one that started a run longest ago.
11
+ * 3. **Oldest first.**
12
+ *
13
+ * And these always hold:
14
+ *
15
+ * - Runs in one conversation take turns: only the oldest run of a lane can
16
+ * start, and never while another run of that lane is going (a session is
17
+ * what two overlapping turns would corrupt).
18
+ * - Capacity counts terminal sessions too. Terminal sessions never wait — the
19
+ * person is at the keyboard — so they shrink what the employees get.
20
+ * - An employee's "Up to N" (`max_concurrent`) caps its own runs.
21
+ * - "Keep one ready" (`keep_one_ready`) keeps one slot free for an employee
22
+ * that has nothing running, so its next run starts at once: other employees
23
+ * cannot take that slot.
24
+ * - Held (paused, memory pressure, battery): nothing new starts.
25
+ */
26
+ export interface QueuedRun {
27
+ runId: string;
28
+ agentId: string;
29
+ /** Runs sharing a lane (employee + conversation) never overlap. */
30
+ lane: string;
31
+ /** Someone is waiting on this run. */
32
+ waiting: boolean;
33
+ /** When it was queued (ms). Earlier first, among equals. */
34
+ queuedAt: number;
35
+ }
36
+ export interface EmployeeLimits {
37
+ /** `null`: no limit of its own. */
38
+ maxConcurrent: number | null;
39
+ keepOneReady: boolean;
40
+ }
41
+ export declare const NO_LIMITS: EmployeeLimits;
42
+ export interface SchedulerConfig {
43
+ capacity: number;
44
+ held: boolean;
45
+ /** Terminal sessions open now. */
46
+ terminals: number;
47
+ /** Every employee placed here, with its limits. */
48
+ employees: ReadonlyMap<string, EmployeeLimits>;
49
+ }
50
+ export declare class HostScheduler<T extends QueuedRun = QueuedRun> {
51
+ private queue;
52
+ private readonly running;
53
+ private readonly lastStarted;
54
+ private tick;
55
+ /** Queue a run. A run already queued or running is ignored (returns false). */
56
+ add(run: T): boolean;
57
+ /** Drop a queued run (it was claimed elsewhere, cancelled, or moved off). */
58
+ remove(runId: string): T | undefined;
59
+ /** Every queued run, oldest first. */
60
+ queued(): readonly T[];
61
+ has(runId: string): boolean;
62
+ get busy(): number;
63
+ runningFor(agentId: string): number;
64
+ /**
65
+ * The next run to start under `config`, or `undefined`. `eligible` lets the
66
+ * caller veto a run it can't start yet (its AI login is resting); a vetoed
67
+ * run keeps its place and does not block the others.
68
+ *
69
+ * Taking it marks it running; call {@link finish} when it is done.
70
+ */
71
+ take(config: SchedulerConfig, eligible?: (run: T) => boolean): T | undefined;
72
+ /** A started run is done. */
73
+ finish(runId: string): void;
74
+ /** A taken run could not start after all: back in the queue, in its old place. */
75
+ requeue(run: T): void;
76
+ /** Slots kept for "keep one ready" employees other than `agentId` that have nothing running. */
77
+ private reservedFor;
78
+ private compare;
79
+ }
80
+ /**
81
+ * Whether someone is waiting on a run: the server's own `waiting` when it says,
82
+ * else a mention / DM / customer message that expects a reply.
83
+ */
84
+ export declare function someoneWaiting(data: Record<string, unknown>): boolean;
85
+ /** The lane a run waits on: its employee and conversation (or task / cleaning), else itself. */
86
+ export declare function laneOf(data: Record<string, unknown>, runId: string): string;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Which waiting run starts next on this computer.
3
+ *
4
+ * One computer serves many employees, so "oldest first" alone would let one
5
+ * busy employee crowd out the rest. The order is:
6
+ *
7
+ * 1. **Someone waiting** — a mention, a DM, a customer's message — before
8
+ * background work (a trigger, a scheduled job).
9
+ * 2. **Take turns between employees** — the employee with the fewest runs going,
10
+ * then the one that started a run longest ago.
11
+ * 3. **Oldest first.**
12
+ *
13
+ * And these always hold:
14
+ *
15
+ * - Runs in one conversation take turns: only the oldest run of a lane can
16
+ * start, and never while another run of that lane is going (a session is
17
+ * what two overlapping turns would corrupt).
18
+ * - Capacity counts terminal sessions too. Terminal sessions never wait — the
19
+ * person is at the keyboard — so they shrink what the employees get.
20
+ * - An employee's "Up to N" (`max_concurrent`) caps its own runs.
21
+ * - "Keep one ready" (`keep_one_ready`) keeps one slot free for an employee
22
+ * that has nothing running, so its next run starts at once: other employees
23
+ * cannot take that slot.
24
+ * - Held (paused, memory pressure, battery): nothing new starts.
25
+ */
26
+ export const NO_LIMITS = Object.freeze({ maxConcurrent: null, keepOneReady: false });
27
+ export class HostScheduler {
28
+ queue = [];
29
+ running = new Map();
30
+ lastStarted = new Map();
31
+ tick = 0;
32
+ /** Queue a run. A run already queued or running is ignored (returns false). */
33
+ add(run) {
34
+ if (this.running.has(run.runId) || this.queue.some((q) => q.runId === run.runId))
35
+ return false;
36
+ this.queue.push(run);
37
+ return true;
38
+ }
39
+ /** Drop a queued run (it was claimed elsewhere, cancelled, or moved off). */
40
+ remove(runId) {
41
+ const index = this.queue.findIndex((q) => q.runId === runId);
42
+ if (index === -1)
43
+ return undefined;
44
+ return this.queue.splice(index, 1)[0];
45
+ }
46
+ /** Every queued run, oldest first. */
47
+ queued() {
48
+ return this.queue;
49
+ }
50
+ has(runId) {
51
+ return this.running.has(runId) || this.queue.some((q) => q.runId === runId);
52
+ }
53
+ get busy() {
54
+ return this.running.size;
55
+ }
56
+ runningFor(agentId) {
57
+ let count = 0;
58
+ for (const entry of this.running.values())
59
+ if (entry.agentId === agentId)
60
+ count += 1;
61
+ return count;
62
+ }
63
+ /**
64
+ * The next run to start under `config`, or `undefined`. `eligible` lets the
65
+ * caller veto a run it can't start yet (its AI login is resting); a vetoed
66
+ * run keeps its place and does not block the others.
67
+ *
68
+ * Taking it marks it running; call {@link finish} when it is done.
69
+ */
70
+ take(config, eligible = () => true) {
71
+ if (config.held)
72
+ return undefined;
73
+ const busyLanes = new Set([...this.running.values()].map((r) => r.lane));
74
+ const seenLanes = new Set();
75
+ const candidates = [];
76
+ for (const run of this.queue) {
77
+ // Only the oldest run of each lane is a candidate: later ones wait their turn.
78
+ if (seenLanes.has(run.lane))
79
+ continue;
80
+ seenLanes.add(run.lane);
81
+ if (busyLanes.has(run.lane))
82
+ continue;
83
+ candidates.push(run);
84
+ }
85
+ const free = config.capacity - this.running.size - config.terminals;
86
+ if (free <= 0)
87
+ return undefined;
88
+ const ordered = candidates.sort((a, b) => this.compare(a, b));
89
+ for (const run of ordered) {
90
+ const limits = config.employees.get(run.agentId) ?? NO_LIMITS;
91
+ const mine = this.runningFor(run.agentId);
92
+ if (limits.maxConcurrent !== null && mine >= limits.maxConcurrent)
93
+ continue;
94
+ // A "keep one ready" employee with nothing going takes the slot kept for
95
+ // it; anyone else must leave the kept slots free.
96
+ const ownReserved = limits.keepOneReady && mine === 0 && limits.maxConcurrent !== 0;
97
+ if (!ownReserved && free - this.reservedFor(config, run.agentId) < 1)
98
+ continue;
99
+ if (!eligible(run))
100
+ continue;
101
+ this.queue = this.queue.filter((q) => q !== run);
102
+ this.running.set(run.runId, { agentId: run.agentId, lane: run.lane });
103
+ this.tick += 1;
104
+ this.lastStarted.set(run.agentId, this.tick);
105
+ return run;
106
+ }
107
+ return undefined;
108
+ }
109
+ /** A started run is done. */
110
+ finish(runId) {
111
+ this.running.delete(runId);
112
+ }
113
+ /** A taken run could not start after all: back in the queue, in its old place. */
114
+ requeue(run) {
115
+ this.running.delete(run.runId);
116
+ if (this.queue.some((q) => q.runId === run.runId))
117
+ return;
118
+ const index = this.queue.findIndex((q) => q.queuedAt > run.queuedAt);
119
+ if (index === -1)
120
+ this.queue.push(run);
121
+ else
122
+ this.queue.splice(index, 0, run);
123
+ }
124
+ /** Slots kept for "keep one ready" employees other than `agentId` that have nothing running. */
125
+ reservedFor(config, agentId) {
126
+ let reserved = 0;
127
+ for (const [other, limits] of config.employees) {
128
+ if (other === agentId || !limits.keepOneReady)
129
+ continue;
130
+ if (limits.maxConcurrent === 0)
131
+ continue;
132
+ if (this.runningFor(other) === 0)
133
+ reserved += 1;
134
+ }
135
+ return reserved;
136
+ }
137
+ compare(a, b) {
138
+ if (a.waiting !== b.waiting)
139
+ return a.waiting ? -1 : 1;
140
+ const runningA = this.runningFor(a.agentId);
141
+ const runningB = this.runningFor(b.agentId);
142
+ if (runningA !== runningB)
143
+ return runningA - runningB;
144
+ const lastA = this.lastStarted.get(a.agentId) ?? 0;
145
+ const lastB = this.lastStarted.get(b.agentId) ?? 0;
146
+ if (lastA !== lastB)
147
+ return lastA - lastB;
148
+ return a.queuedAt - b.queuedAt;
149
+ }
150
+ }
151
+ /**
152
+ * Run kinds where a person is waiting on the answer (`AIAgentRunKind`): a
153
+ * mention anywhere, a guest's message the employee owns (`autopilot`), "Run
154
+ * now", and a chat turn. Heartbeats, schedules, assignments and code reviews
155
+ * are background work.
156
+ */
157
+ const WAITING_KINDS = new Set(['mention', 'autopilot', 'manual', 'ask_ai', 'dm', 'direct_message', 'customer_message', 'guest_message']);
158
+ /** Trigger surfaces where a person is waiting (`trigger.source`). */
159
+ const WAITING_SOURCES = new Set(['team_chat', 'dm', 'direct_message', 'conversation', 'guest_message', 'customer_message', 'task_comment', 'cleaning_comment', 'desk', 'ask_ai']);
160
+ /**
161
+ * Whether someone is waiting on a run: the server's own `waiting` when it says,
162
+ * else a mention / DM / customer message that expects a reply.
163
+ */
164
+ export function someoneWaiting(data) {
165
+ if (typeof data.waiting === 'boolean')
166
+ return data.waiting;
167
+ if (data.reply_expected === false)
168
+ return false;
169
+ const kind = typeof data.kind === 'string' ? data.kind.toLowerCase() : '';
170
+ const trigger = data.trigger && typeof data.trigger === 'object' ? data.trigger : {};
171
+ const rawSource = typeof trigger.source === 'string' ? trigger.source : data.trigger_source;
172
+ const source = typeof rawSource === 'string' ? rawSource.toLowerCase() : '';
173
+ return WAITING_KINDS.has(kind) || kind.endsWith('_mention') || WAITING_SOURCES.has(source);
174
+ }
175
+ /** The lane a run waits on: its employee and conversation (or task / cleaning), else itself. */
176
+ export function laneOf(data, runId) {
177
+ const agent = typeof data.agent_id === 'string' ? data.agent_id : 'agent';
178
+ const surface = typeof data.conversation_id === 'string' && data.conversation_id
179
+ ? `conversation:${data.conversation_id}`
180
+ : typeof data.task_id === 'string' && data.task_id
181
+ ? `task:${data.task_id}`
182
+ : typeof data.cleaning_id === 'string' && data.cleaning_id
183
+ ? `cleaning:${data.cleaning_id}`
184
+ : undefined;
185
+ return surface ? `${agent}/${surface}` : `run:${runId}`;
186
+ }