@prohost/cli 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,11 +20,12 @@
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { createHash } from 'node:crypto';
23
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
  import { buildLaunchdPlist, waitUntilUnloaded } from '../agent/daemon.js';
27
27
  import { TERMINAL_SESSIONS_ENV } from '../codehost/settings.js';
28
+ import { SERVICE_LABEL, readSharedObject } from './computer.js';
28
29
  export const LAUNCHD_HOST_LABEL = 'ai.prohost.host';
29
30
  export const SYSTEMD_HOST_UNIT = 'prohost-host.service';
30
31
  export const WINDOWS_TASK_NAME = '\\ProhostAI\\Host';
@@ -46,7 +47,7 @@ export const defaultServiceRunner = (command, args, input) => {
46
47
  return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? result.error?.message ?? '' };
47
48
  };
48
49
  const SERVICE_USER = /^[A-Za-z0-9._-][A-Za-z0-9._ -]{0,63}$/;
49
- const LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
50
+ const LABEL = SERVICE_LABEL;
50
51
  /** Paths for the OS the service is for (tests build every OS's files on one). */
51
52
  function joinFor(platform) {
52
53
  return platform === 'win32' ? path.win32.join : path.posix.join;
@@ -197,6 +198,15 @@ export function windowsWrapperPath(spec) {
197
198
  export function windowsServiceDir(spec) {
198
199
  return path.win32.join('C:\\ProgramData\\Prohost\\services', labelOf(spec));
199
200
  }
201
+ /**
202
+ * The administrators' copy of a Windows dedicated service's key (`services\<label>`: the account
203
+ * may read it, never change it) — what an administrator's `unpair` / `uninstall` reads. Written
204
+ * when an install creates the service's home; a home installed before 0.16.5 has none, and is
205
+ * then never handed a note nor unpaired by label (its computer is removed on its page).
206
+ */
207
+ export function windowsServiceCredentialPath(spec) {
208
+ return path.win32.join(windowsServiceDir(spec), 'computer.json');
209
+ }
200
210
  /** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
201
211
  export function buildWindowsTaskXml(spec, currentUser) {
202
212
  const user = xmlEscape(spec.dedicated && spec.serviceUser ? spec.serviceUser : currentUser);
@@ -267,7 +277,12 @@ function failed(step, outcome) {
267
277
  /** Validate a spec before anything is written. */
268
278
  export function specProblem(spec, io = {}, setup = {}) {
269
279
  if (spec.label !== undefined && !LABEL.test(spec.label)) {
270
- return `"${spec.label}" is not a usable label (letters, digits, "." and "-", like ai.prohost.desktop.host).`;
280
+ return `"${spec.label}" is not a usable label (letters, digits, "." and "-", like ai.prohost.host.office).`;
281
+ }
282
+ // A dedicated service is one root may later remove by its label (`host unpair --label`), which
283
+ // only ever acts on ours: the label says so.
284
+ if (spec.dedicated && spec.label !== undefined && !spec.label.startsWith(LAUNCHD_HOST_LABEL)) {
285
+ return `A dedicated service's label starts with ${LAUNCHD_HOST_LABEL} (for example ${LAUNCHD_HOST_LABEL}.office), not "${spec.label}".`;
271
286
  }
272
287
  if (setup.claudeTokenFile && !spec.dedicated)
273
288
  return '--claude-token-file is for a dedicated install (--dedicated).';
@@ -359,6 +374,247 @@ function mkdirOnce(dir) {
359
374
  throw err;
360
375
  }
361
376
  }
377
+ /**
378
+ * The home (and account) an installed dedicated service runs with, read from its definition —
379
+ * root's / the administrators' own file (`/Library/LaunchDaemons`, `/etc/systemd/system`,
380
+ * `ProgramData\\Prohost\\services`) — or undefined when none is installed under `label`. What
381
+ * an administrator's `unpair` / `uninstall` trusts, never a note in an account's home.
382
+ */
383
+ export function installedDedicatedService(platform, label, io = {}) {
384
+ if (!LABEL.test(label))
385
+ return undefined;
386
+ const read = reader(io);
387
+ const unxml = (v) => v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
388
+ if (platform === 'darwin') {
389
+ const plist = read(path.join('/Library/LaunchDaemons', `${label}.plist`));
390
+ const home = plist && /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1];
391
+ const user = plist && /<key>UserName<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1];
392
+ return home ? { home: unxml(home), ...(user ? { user: unxml(user) } : {}) } : undefined;
393
+ }
394
+ if (platform === 'linux') {
395
+ const unit = read(path.join('/etc/systemd/system', systemdUnitOf({ label })));
396
+ const quoted = unit && /^WorkingDirectory="((?:[^"\\]|\\.)*)"$/m.exec(unit)?.[1];
397
+ const user = unit && /^User=(\S+)$/m.exec(unit)?.[1];
398
+ const home = quoted?.replace(/%%/g, '%').replace(/\\(.)/g, '$1');
399
+ return home ? { home, ...(user ? { user } : {}) } : undefined;
400
+ }
401
+ if (platform === 'win32') {
402
+ const wrapper = read(path.win32.join(windowsServiceDir({ label }), 'host-service.cmd'));
403
+ const home = wrapper && /^set "PROHOST_HOME=([^"\r\n]*)"\r?$/m.exec(wrapper)?.[1];
404
+ return home ? { home: home.replace(/%%/g, '%') } : undefined;
405
+ }
406
+ return undefined;
407
+ }
408
+ /**
409
+ * A dedicated service home's `computer.json` text, when it holds a key, read the safe way for an
410
+ * administrator: on macOS / Linux as the service's account (`sudo -u` / `runuser … cat`), so a link
411
+ * the account planted at `host` or `computer.json` reaches only what that account may read itself;
412
+ * on Windows from the service's administrators-only copy, never from the account's home.
413
+ */
414
+ export function readServiceCredential(platform, home, user, io = {}, label) {
415
+ const join = joinFor(platform);
416
+ const dir = join(home, 'host');
417
+ const file = join(dir, 'computer.json');
418
+ const uid = io.uid ?? process.getuid?.() ?? 1;
419
+ if (platform === 'win32') {
420
+ // Never from the account's home (it can swap a junction in there while an administrator opens
421
+ // a path, and Windows can't read as the account): from the copy the install put in the
422
+ // service's administrators-only folder (`windowsServiceCredentialPath`), read through one handle.
423
+ if (!label)
424
+ return undefined;
425
+ const opened = (() => {
426
+ try {
427
+ return (io.readHandle ?? readThroughHandle)(windowsServiceCredentialPath({ label }));
428
+ }
429
+ catch {
430
+ return undefined;
431
+ }
432
+ })();
433
+ if (!opened || !opened.isFile || opened.nlink !== 1)
434
+ return undefined;
435
+ return keyedCredential(opened.text);
436
+ }
437
+ if (uid !== 0 || !user)
438
+ return keyedCredential(reader(io)(file));
439
+ const read = asUser(io.run ?? defaultServiceRunner, platform, user, 'cat "$1"', [file]);
440
+ return read.status === 0 ? keyedCredential(read.stdout) : undefined;
441
+ }
442
+ /**
443
+ * What a dedicated service last published about its computer (`hosts\\<label>\\state.json`): its id,
444
+ * `none` when it has published nothing yet (no file, or the `{}` an install starts it with), or
445
+ * `unreadable` (a read error or JSON that does not parse, even after a retry) — never taken as none.
446
+ */
447
+ export function publishedComputer(spec, io = {}) {
448
+ const file = joinFor(spec.platform)(sharedDirFor(spec), 'state.json');
449
+ const parse = () => {
450
+ if (io.readFile) {
451
+ const text = io.readFile(file);
452
+ return text === undefined ? null : JSON.parse(text);
453
+ }
454
+ if (!(io.exists ?? existsSync)(file))
455
+ return null;
456
+ // The service rewrites it in place: a read caught mid-write is tried again (`readSharedObject`).
457
+ const state = readSharedObject(file);
458
+ if (state === undefined)
459
+ throw new Error('unreadable');
460
+ return state;
461
+ };
462
+ let state;
463
+ try {
464
+ state = parse();
465
+ }
466
+ catch {
467
+ return { kind: 'unreadable' };
468
+ }
469
+ if (state === null)
470
+ return { kind: 'none' };
471
+ if (typeof state !== 'object' || Array.isArray(state))
472
+ return { kind: 'unreadable' };
473
+ const id = state.computer?.id;
474
+ return typeof id === 'string' && id ? { kind: 'published', id } : { kind: 'none' };
475
+ }
476
+ /** The computer id a dedicated service last published, if it did (see `publishedComputer`). */
477
+ export function publishedComputerId(spec, io = {}) {
478
+ const published = publishedComputer(spec, io);
479
+ return published.kind === 'published' ? published.id : undefined;
480
+ }
481
+ /** One open: `fstat` and the text read through the same descriptor. */
482
+ function readThroughHandle(file) {
483
+ let fd;
484
+ try {
485
+ fd = openSync(file, 'r');
486
+ }
487
+ catch {
488
+ return undefined;
489
+ }
490
+ try {
491
+ const stat = fstatSync(fd);
492
+ return { isFile: stat.isFile(), nlink: stat.nlink, text: stat.isFile() ? readFileSync(fd, 'utf8') : '' };
493
+ }
494
+ finally {
495
+ closeSync(fd);
496
+ }
497
+ }
498
+ /** `computer.json`'s text when it holds a computer key — never a hand-off note (`ComputerHandoff`), which is copied nowhere. */
499
+ function keyedCredential(text) {
500
+ if (!text)
501
+ return undefined;
502
+ try {
503
+ const parsed = JSON.parse(text);
504
+ return typeof parsed.api_key === 'string' && parsed.api_key && typeof parsed.computer_id === 'string' ? text : undefined;
505
+ }
506
+ catch {
507
+ return undefined;
508
+ }
509
+ }
510
+ /**
511
+ * Write (`content`) or remove (`undefined`) `<home>/host/computer.json` in a home another user
512
+ * owns — the one a dedicated install took its computer from. As root on macOS / Linux it is done
513
+ * as the folder's owner (root never writes through a path an account controls); anyone else, and
514
+ * an administrator on Windows, writes it directly. Returns what went wrong, if anything.
515
+ */
516
+ export function rewriteHomeCredential(platform, home, content, io = {}) {
517
+ const join = joinFor(platform);
518
+ const dir = join(home, 'host');
519
+ const file = join(dir, 'computer.json');
520
+ const run = io.run ?? defaultServiceRunner;
521
+ const uid = io.uid ?? process.getuid?.() ?? 1;
522
+ if (platform === 'win32') {
523
+ // An administrator in someone else's home: never through a junction or link the account
524
+ // could have put there (`isSymlink` is true for both), and a fresh file renamed into place.
525
+ // Windows offers no owner-run write here the way `sudo -u` does elsewhere: the folders' real
526
+ // paths are checked before the write, between it and the rename, and after. That narrows a
527
+ // folder swapped in between, it does not close it (best effort; a delete has one check).
528
+ const isSymlink = io.isSymlink ?? ((f) => {
529
+ try {
530
+ return lstatSync(f).isSymbolicLink();
531
+ }
532
+ catch {
533
+ return false;
534
+ }
535
+ });
536
+ const linked = [home, dir, file, `${file}.handoff`].find((f) => isSymlink(f));
537
+ if (linked)
538
+ return `${linked} is a link or junction`;
539
+ // The folders are where they say they are: checked right before the write and again after it.
540
+ const real = io.realpath ?? ((f) => realpathSync.native(f));
541
+ const inPlace = () => {
542
+ try {
543
+ const realHome = real(home);
544
+ if (path.win32.resolve(realHome).toLowerCase() !== path.win32.resolve(home).toLowerCase())
545
+ return `${home} resolves to ${realHome}`;
546
+ const realDir = real(dir);
547
+ if (path.win32.resolve(realDir).toLowerCase() !== path.win32.join(path.win32.resolve(realHome), 'host').toLowerCase())
548
+ return `${dir} resolves to ${realDir}`;
549
+ return undefined;
550
+ }
551
+ catch (e) {
552
+ return e instanceof Error ? e.message : String(e);
553
+ }
554
+ };
555
+ const before = inPlace();
556
+ if (before)
557
+ return before;
558
+ try {
559
+ const remove = io.removeFile ?? ((f) => rmSync(f, { force: true }));
560
+ if (content === undefined) {
561
+ remove(file);
562
+ return undefined;
563
+ }
564
+ remove(`${file}.handoff`);
565
+ writer(io)(`${file}.handoff`, content, 0o600, 'wx');
566
+ const between = inPlace();
567
+ if (between)
568
+ return `${between} (moved while writing: nothing renamed)`;
569
+ (io.rename ?? renameSync)(`${file}.handoff`, file);
570
+ const after = inPlace();
571
+ if (after)
572
+ return `${after} (moved while writing: check ${file} by hand)`;
573
+ return undefined;
574
+ }
575
+ catch (e) {
576
+ return e instanceof Error ? e.message : String(e);
577
+ }
578
+ }
579
+ if (uid !== 0) {
580
+ try {
581
+ if (content === undefined)
582
+ (io.removeFile ?? ((f) => rmSync(f, { force: true })))(file);
583
+ else
584
+ writer(io)(file, content, 0o600);
585
+ return undefined;
586
+ }
587
+ catch (e) {
588
+ return e instanceof Error ? e.message : String(e);
589
+ }
590
+ }
591
+ const owner = io.ownerOf?.(dir) ?? ownerOf(dir);
592
+ if (!owner)
593
+ return `${dir} is not there`;
594
+ if (owner.link)
595
+ return `${dir} is a link`;
596
+ if (owner.uid === 0) {
597
+ try {
598
+ if (content === undefined)
599
+ rmSync(file, { force: true });
600
+ else
601
+ writer(io)(file, content, 0o600);
602
+ return undefined;
603
+ }
604
+ catch (e) {
605
+ return e instanceof Error ? e.message : String(e);
606
+ }
607
+ }
608
+ const name = run('id', ['-nu', String(owner.uid)]);
609
+ const user = name.stdout.trim();
610
+ if (name.status !== 0 || !user)
611
+ return failed(`finding the owner of ${dir}`, name);
612
+ const done = content === undefined
613
+ ? asUser(run, platform, user, 'rm -f "$1"', [file])
614
+ : // A fresh file beside it (noclobber), then renamed over: never written through.
615
+ asUser(run, platform, user, 'rm -f "$1.handoff" && set -C && cat > "$1.handoff" && mv -f "$1.handoff" "$1"', [file], content);
616
+ return done.status === 0 ? undefined : failed(`${content === undefined ? 'removing' : 'rewriting'} ${file} as ${user}`, done);
617
+ }
362
618
  function ownerOf(file) {
363
619
  try {
364
620
  const stat = lstatSync(file);
@@ -487,7 +743,7 @@ function prepareDedicated(spec, io, setup) {
487
743
  return problem;
488
744
  const hostDir = join(spec.home, 'host');
489
745
  const credential = setup.adoptFrom && path.resolve(setup.adoptFrom) !== path.resolve(spec.home)
490
- ? read(join(setup.adoptFrom, 'host', 'computer.json'))
746
+ ? keyedCredential(read(join(setup.adoptFrom, 'host', 'computer.json')))
491
747
  : undefined;
492
748
  let token;
493
749
  if (setup.claudeTokenFile) {
@@ -517,6 +773,13 @@ function prepareDedicated(spec, io, setup) {
517
773
  // in one step with no access for the account, filled, and only then opened to it — so it can
518
774
  // never swap a path in it while an administrator writes there. An existing one is never
519
775
  // written into; a --service-home elsewhere is the account's own business.
776
+ // No computer handed to this install (and not a reinstall of the one handed to it before): no
777
+ // administrators' copy of an older one may outlive it.
778
+ if (!credential && !setup.handedOffHere) {
779
+ const copy = windowsServiceCredentialPath(spec);
780
+ if (exists(copy))
781
+ remove(copy);
782
+ }
520
783
  if (path.win32.resolve(path.win32.dirname(spec.home)) === path.win32.resolve(windowsHomesDir())) {
521
784
  const made = (io.mkdirOnce ?? mkdirOnce)(spec.home);
522
785
  if (made === 'exists') {
@@ -525,6 +788,13 @@ function prepareDedicated(spec, io, setup) {
525
788
  const owner = windowsOwner(run, spec.home);
526
789
  if (!/\\(Administrators|SYSTEM)$/i.test(owner))
527
790
  return `${spec.home} is owned by ${owner || 'someone else'}: remove it, then install again.`;
791
+ // A computer handed to a home that already exists but has no administrators' copy of its key
792
+ // (uninstalled before, or installed before 0.16.5): nothing here could vouch for the key in
793
+ // it, so it would run without the hand-off. A fresh home gets both.
794
+ // (A copy that is there but holds no usable key, e.g. an interrupted write, counts as none.)
795
+ if (credential && !setup.handedOffHere && !keyedCredential(read(windowsServiceCredentialPath(spec)))) {
796
+ return `${spec.home} is left from an earlier install and has no administrators' copy of its key: remove that folder, then install again.`;
797
+ }
528
798
  }
529
799
  else {
530
800
  for (const args of [
@@ -536,8 +806,14 @@ function prepareDedicated(spec, io, setup) {
536
806
  return failed(`icacls ${args.slice(0, 2).join(' ')}`, out);
537
807
  }
538
808
  mkdir(hostDir, 0o700);
539
- if (credential)
809
+ if (credential) {
540
810
  write(join(hostDir, 'computer.json'), credential, 0o600, 'wx');
811
+ // The administrators' copy, in the folder just claimed: what root-level commands read.
812
+ const copy = windowsServiceCredentialPath(spec);
813
+ if (exists(copy))
814
+ remove(copy);
815
+ write(copy, credential, 0o600, 'wx');
816
+ }
541
817
  }
542
818
  // The account may now use its home.
543
819
  const open = run('icacls', [spec.home, '/grant', `${user}:(OI)(CI)M`]);
@@ -654,8 +930,13 @@ export function installService(spec, io = {}, setup = {}) {
654
930
  const plist = buildHostPlist(spec);
655
931
  const loaded = run('launchctl', ['print', `${domain}/${label}`]).status === 0;
656
932
  // Re-running it changes nothing that is already right: the service keeps
657
- // working through every app update that didn't move it.
933
+ // working through every app update that didn't move it. Enablement is
934
+ // persistent state separate from whether launchd currently has the job
935
+ // loaded, so repair it before returning through this fast path.
658
936
  if (loaded && read(file) === plist) {
937
+ const enable = run('launchctl', ['enable', `${domain}/${label}`]);
938
+ if (enable.status !== 0)
939
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl enable', enable)}` };
659
940
  return { ok: true, file, message: `✓ ${label} is installed and running (${file})\n logs: ${logs}` };
660
941
  }
661
942
  write(file, plist, spec.dedicated ? 0o644 : 0o600);
@@ -673,12 +954,15 @@ export function installService(spec, io = {}, setup = {}) {
673
954
  timeoutMs: RESTART_WAIT_MS,
674
955
  onWaiting: (line) => process.stderr.write(`${line.replace('agent', 'host service')}\n`),
675
956
  });
957
+ // A disabled label cannot load: enabling must succeed before bootstrap.
958
+ const enable = run('launchctl', ['enable', `${domain}/${label}`]);
959
+ if (enable.status !== 0)
960
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl enable', enable)}` };
676
961
  const boot = run('launchctl', ['bootstrap', domain, file]);
677
962
  if (boot.status !== 0) {
678
963
  const still = oldGone ? '' : ` (the previous host service was still finishing its runs after ${RESTART_WAIT_MS / 1000}s)`;
679
964
  return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl bootstrap', boot)}${still}` };
680
965
  }
681
- run('launchctl', ['enable', `${domain}/${label}`]);
682
966
  return { ok: true, file, message: `✓ Installed ${label} (${file})\n logs: ${logs}` };
683
967
  }
684
968
  if (spec.platform === 'linux') {
@@ -768,6 +1052,32 @@ export function uninstallService(spec, io = {}) {
768
1052
  if (exists(file))
769
1053
  remove(file);
770
1054
  }
1055
+ // The administrators' copy of its key goes with the service: a reinstall never finds an old one.
1056
+ const copy = windowsServiceCredentialPath(spec);
1057
+ if (out.status === 0 && exists(copy)) {
1058
+ try {
1059
+ remove(copy);
1060
+ }
1061
+ catch (e) {
1062
+ notes.push(`could not delete ${copy} (${e instanceof Error ? e.message : String(e)}): delete it yourself`);
1063
+ }
1064
+ }
1065
+ }
1066
+ // A dedicated service gone: its published state goes too, so nothing (the desktop's note check)
1067
+ // reads a service that ran once as one that is installed.
1068
+ // (Windows: a task by that label, removed with or without --dedicated; only a dedicated one publishes state.)
1069
+ const dedicatedGone = spec.platform === 'win32'
1070
+ ? removed.length > 0
1071
+ : removed.some((file) => file === serviceFilePath({ ...spec, dedicated: true }));
1072
+ if (dedicatedGone) {
1073
+ const state = joinFor(spec.platform)(sharedDirFor(spec), 'state.json');
1074
+ try {
1075
+ if (exists(state))
1076
+ remove(state);
1077
+ }
1078
+ catch {
1079
+ /* the next install rewrites it */
1080
+ }
771
1081
  }
772
1082
  if (removed.length === 0 && notes.length === 0) {
773
1083
  return { ok: true, message: 'No host service was installed. Nothing to remove.' };
@@ -49,7 +49,7 @@ import { loadCredentials, prohostHome } from '../agent/credentials.js';
49
49
  import { LAUNCHD_LABEL_PREFIX } from '../agent/daemon.js';
50
50
  import { resolveAgentAccount } from '../agent/accounts.js';
51
51
  import { PAIRING_ADOPT_PATH, callJson } from './api.js';
52
- import { machineFacts } from './computer.js';
52
+ import { handoffNotice, machineFacts, readHandoff } from './computer.js';
53
53
  import { credentialsFrom } from './pair.js';
54
54
  import { isCodexExec } from '../agent/codex.js';
55
55
  import { SessionStore } from '../agent/sessionstore.js';
@@ -328,6 +328,10 @@ export async function adoptComputer(options) {
328
328
  const env = options.env ?? process.env;
329
329
  if (tryLoadComputer(env))
330
330
  return { status: 'already_paired' };
331
+ // Handed to a dedicated service: never a second key over its note.
332
+ const handedOff = readHandoff(env);
333
+ if (handedOff)
334
+ return { status: 'not_adopted', reason: handoffNotice(handedOff) };
331
335
  let facts;
332
336
  try {
333
337
  facts = options.facts ?? machineFacts({ env, name: options.name });
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import type { ProhostAccount } from '../agent/credentials.js';
23
23
  import type { ComputerCredentials, MachineFacts } from './computer.js';
24
- export declare const HOST_PAIR_OUTCOMES: readonly ["paired", "expired", "token_rejected", "workspace_unconfirmed", "declined", "rate_limited", "unreachable", "invalid_arguments", "failed"];
24
+ export declare const HOST_PAIR_OUTCOMES: readonly ["paired", "expired", "token_rejected", "workspace_unconfirmed", "declined", "rate_limited", "unreachable", "invalid_arguments", "handed_off", "failed"];
25
25
  export type HostPairOutcome = (typeof HOST_PAIR_OUTCOMES)[number];
26
26
  export interface HostPairResult {
27
27
  version: 1;
package/dist/host/pair.js CHANGED
@@ -38,6 +38,8 @@ export const HOST_PAIR_OUTCOMES = [
38
38
  'rate_limited',
39
39
  'unreachable',
40
40
  'invalid_arguments',
41
+ /** This home handed its computer to a dedicated service (`host install --dedicated`): it pairs nothing. */
42
+ 'handed_off',
41
43
  'failed',
42
44
  ];
43
45
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -67,8 +67,12 @@ export declare class HostScheduler<T extends QueuedRun = QueuedRun> {
67
67
  * run keeps its place and does not block the others.
68
68
  *
69
69
  * Taking it marks it running; call {@link finish} when it is done.
70
+ *
71
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
72
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
73
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
70
74
  */
71
- take(config: SchedulerConfig, eligible?: (run: T) => boolean): T | undefined;
75
+ take(config: SchedulerConfig, eligible?: (run: T) => boolean, parked?: (run: T) => boolean): T | undefined;
72
76
  /** A started run is done. */
73
77
  finish(runId: string): void;
74
78
  /** A taken run could not start after all: back in the queue, in its old place. */
@@ -66,14 +66,20 @@ export class HostScheduler {
66
66
  * run keeps its place and does not block the others.
67
67
  *
68
68
  * Taking it marks it running; call {@link finish} when it is done.
69
+ *
70
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
71
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
72
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
69
73
  */
70
- take(config, eligible = () => true) {
74
+ take(config, eligible = () => true, parked = () => false) {
71
75
  if (config.held)
72
76
  return undefined;
73
77
  const busyLanes = new Set([...this.running.values()].map((r) => r.lane));
74
78
  const seenLanes = new Set();
75
79
  const candidates = [];
76
80
  for (const run of this.queue) {
81
+ if (parked(run))
82
+ continue;
77
83
  // Only the oldest run of each lane is a candidate: later ones wait their turn.
78
84
  if (seenLanes.has(run.lane))
79
85
  continue;
@@ -112,6 +112,11 @@ export interface HostServiceResult {
112
112
  runsStarted: number;
113
113
  runsSkipped: number;
114
114
  }
115
+ /**
116
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
117
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
118
+ */
119
+ export declare function busyBackoffMs(waits: number): number;
115
120
  /** The CLI's own entry file: when it changes on disk, an update was installed. */
116
121
  export declare function entrypointStamp(entry?: string): () => boolean;
117
122
  export declare function runHostService(options: HostServiceOptions): Promise<HostServiceResult>;
@@ -38,7 +38,7 @@ import { HeartbeatLoop } from '../agent/heartbeat.js';
38
38
  import { executeHostedRun } from '../agent/run.js';
39
39
  import { SessionStore, sessionKeyFor } from '../agent/sessionstore.js';
40
40
  import { verifySignature } from '../agent/signature.js';
41
- import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
41
+ import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
42
42
  import { HostStatsSampler } from '../codehost/host_stats.js';
43
43
  import { TERMINAL_SESSIONS_ENV, loadSettings as loadCodeHostSettings, recordServiceSwitch } from '../codehost/settings.js';
44
44
  import { isWebhookEvent } from '../forward.js';
@@ -82,6 +82,13 @@ const ACK_RETRY = 503;
82
82
  const RECONNECT_MAX_DELAY_MS = 30_000;
83
83
  /** An employee id names a folder: nothing else gets in. */
84
84
  const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
85
+ /**
86
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
87
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
88
+ */
89
+ export function busyBackoffMs(waits) {
90
+ return Math.min(30_000 * 2 ** waits, 120_000);
91
+ }
85
92
  function timestamp() {
86
93
  return new Date().toISOString().slice(11, 19);
87
94
  }
@@ -241,8 +248,12 @@ async function serveHost(options) {
241
248
  if (codeHost?.authorized() && config.terminal_sessions_enabled) {
242
249
  caps.push(CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability());
243
250
  caps.push(...(codeHost.agentCapabilities?.() ?? []));
244
- if (codeHost.remoteTerminalAllowed())
251
+ if (codeHost.remoteTerminalAllowed()) {
245
252
  caps.push(REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY);
253
+ // Relaunching a running thread's agent without prompts (`code_thread.restart_requested`): the same rule.
254
+ if (codeHost.handleRestart)
255
+ caps.push(CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY);
256
+ }
246
257
  }
247
258
  return caps;
248
259
  }
@@ -260,6 +271,8 @@ async function serveHost(options) {
260
271
  }
261
272
  /** Runs being claimed right now, and the login each will run on. */
262
273
  const claiming = new Map();
274
+ /** Runs the server resent while their claim was in flight: a `conversation_busy` answer to that claim is already stale. */
275
+ const resentWhileClaiming = new Set();
263
276
  function runsOnLogin(accountKey) {
264
277
  let n = 0;
265
278
  for (const run of active.values())
@@ -489,7 +502,9 @@ async function serveHost(options) {
489
502
  return false;
490
503
  const pick = pool.pick(runtimeOf(run.agentId, run.data), conversationOf(run.lane), config.share_logins[runtimeOf(run.agentId, run.data)], runsOnLogin);
491
504
  return pick.kind === 'use' || pick.kind === 'move';
492
- });
505
+ },
506
+ // Waiting on an earlier turn of its conversation: that turn may be queued behind it.
507
+ (run) => Boolean(run.busyWaits) && run.notBefore > now());
493
508
  if (!next)
494
509
  break;
495
510
  background.push(startRun(next));
@@ -516,6 +531,19 @@ async function serveHost(options) {
516
531
  const claim = await claimRun(computerApi, computer.computer_id, queued.runId).finally(() => {
517
532
  claiming.delete(queued.runId);
518
533
  });
534
+ const resent = resentWhileClaiming.delete(queued.runId);
535
+ if (claim.kind === 'busy' && !draining) {
536
+ // Serialized server-side: an earlier turn of this conversation is still open. Not dropped,
537
+ // not failed: queued again (its slot is free for other work) until the server resends its
538
+ // frame, or its backoff ends. A resend that came while this claim was in flight already
539
+ // said the earlier turn ended: claimed again at once.
540
+ const waits = queued.busyWaits ?? 0;
541
+ scheduler.requeue({ ...queued, busyWaits: waits + 1, notBefore: resent ? 0 : now() + busyBackoffMs(waits) });
542
+ if (waits === 0)
543
+ log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} waits — an earlier turn of its conversation is still open`);
544
+ pump();
545
+ return;
546
+ }
519
547
  if (claim.kind !== 'claimed') {
520
548
  if (claim.kind === 'retry' && queued.claimAttempts + 1 < MAX_CLAIM_ATTEMPTS && !draining) {
521
549
  scheduler.requeue({ ...queued, claimAttempts: queued.claimAttempts + 1, notBefore: now() + 2_000 * 2 ** queued.claimAttempts });
@@ -525,7 +553,7 @@ async function serveHost(options) {
525
553
  result.runsSkipped += 1;
526
554
  const why = claim.kind === 'elsewhere'
527
555
  ? 'another computer has it'
528
- : claim.kind === 'gone'
556
+ : claim.kind === 'gone' || claim.kind === 'busy'
529
557
  ? 'it is no longer waiting'
530
558
  : `the claim was refused (${claim.status}${'error' in claim && claim.error ? `: ${claim.error}` : ''})`;
531
559
  log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} skipped — ${why}`);
@@ -948,7 +976,8 @@ async function serveHost(options) {
948
976
  }
949
977
  if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
950
978
  frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
951
- frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED) {
979
+ frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED ||
980
+ frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED) {
952
981
  if (!codeHost || !config.terminal_sessions_enabled) {
953
982
  ack(ACK_IGNORED);
954
983
  return;
@@ -980,7 +1009,9 @@ async function serveHost(options) {
980
1009
  ? codeHost.handleStart(data)
981
1010
  : frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED
982
1011
  ? codeHost.handleInput(data, frame.delivery_id)
983
- : codeHost.handleApproval(data, frame.delivery_id);
1012
+ : frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
1013
+ ? (codeHost.handleRestart?.(data, frame.delivery_id) ?? Promise.resolve(false))
1014
+ : codeHost.handleApproval(data, frame.delivery_id);
984
1015
  background.push(handled
985
1016
  .then((ok) => {
986
1017
  ack(starting || ok ? ACK_ACCEPTED : ACK_IGNORED);
@@ -1016,8 +1047,19 @@ async function serveHost(options) {
1016
1047
  return [ACK_IGNORED];
1017
1048
  if (typeof data.computer_id === 'string' && data.computer_id !== computer.computer_id)
1018
1049
  return [ACK_IGNORED];
1019
- if (scheduler.has(runId) || active.has(runId) || ledger.get(runId))
1050
+ if (scheduler.has(runId) || active.has(runId) || ledger.get(runId)) {
1051
+ // The server's resend of a run waiting on its busy conversation: the earlier turn ended, so
1052
+ // it is claimed now rather than at the end of its backoff.
1053
+ const waiting = scheduler.queued().find((run) => run.runId === runId);
1054
+ if (waiting?.busyWaits && waiting.notBefore > now()) {
1055
+ waiting.notBefore = 0;
1056
+ pump();
1057
+ }
1058
+ else if (claiming.has(runId)) {
1059
+ resentWhileClaiming.add(runId);
1060
+ }
1020
1061
  return [ACK_ACCEPTED];
1062
+ }
1021
1063
  if (draining)
1022
1064
  return [ACK_RETRY, 'draining'];
1023
1065
  if (!config.ai_employees_enabled)
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ function printHelp() {
33
33
  ' prohost host pair [--name <name>] [--token <redeem-token>|-] [--expect-account <workspace-id>]',
34
34
  ' Add this computer to a workspace (prints a code to enter in ProhostAI)',
35
35
  ' prohost host unpair Remove this computer from its workspace (revokes its key)',
36
+ ' sudo prohost host unpair --label <id> [--service-user <name>] Remove a dedicated service\'s computer and uninstall it',
36
37
  ' prohost host run One service for every AI employee placed on this computer',
37
38
  ' prohost host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>]',
38
39
  ' [--claude-token-file <path>] [--remote-terminal|--no-remote-terminal]] | uninstall [--label <id>]',
@@ -81,7 +82,10 @@ function printHelp() {
81
82
  ' or one derived from $PROHOST_HOME. Re-running install changes nothing that is right.',
82
83
  ' Under the ProhostAI desktop app, host install keeps ELECTRON_RUN_AS_NODE=1 in the service it writes',
83
84
  ' (and the PATH it was launched with), so the service runs the CLI, never the app.',
84
- ' `host unpair` removes this computer from its workspace and deletes its key here.',
85
+ ' `host unpair` removes this computer from its workspace and deletes its key here. A dedicated install',
86
+ ' takes the key over: the home it came from keeps a note instead, and `unpair` there',
87
+ ' points at `sudo prohost host unpair --label <id>`, which uses the service\'s key, uninstalls',
88
+ ' the service and clears both homes.',
85
89
  ' `host logins add` signs in another Claude Code / Codex login in this terminal.',
86
90
  ' `host pause` takes no new work until `host resume`; work already going finishes.',
87
91
  ' `host migrate` adopts existing agent homes and the desktop app\'s terminal-hosting home, keeping',