@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.
- package/CHANGELOG.md +53 -0
- package/README.md +21 -4
- package/dist/agent/daemon.js +12 -1
- package/dist/agent/run.js +12 -4
- package/dist/codehost/hooks.d.ts +2 -0
- package/dist/codehost/hooks.js +15 -1
- package/dist/codehost/host.d.ts +24 -5
- package/dist/codehost/host.js +85 -3
- package/dist/codehost/ledger.d.ts +16 -2
- package/dist/codehost/ledger.js +43 -6
- package/dist/codehost/ready.d.ts +8 -9
- package/dist/codehost/ready.js +41 -9
- package/dist/codehost/session.d.ts +25 -0
- package/dist/codehost/session.js +61 -0
- package/dist/codehost/terminals.d.ts +119 -1
- package/dist/codehost/terminals.js +369 -5
- package/dist/codehost/tmux.d.ts +6 -0
- package/dist/codehost/tmux.js +9 -0
- package/dist/host/api.d.ts +7 -1
- package/dist/host/api.js +15 -1
- package/dist/host/command.js +334 -9
- package/dist/host/computer.d.ts +31 -0
- package/dist/host/computer.js +36 -0
- package/dist/host/install.d.ts +64 -0
- package/dist/host/install.js +317 -7
- package/dist/host/migrate.js +5 -1
- package/dist/host/pair.d.ts +1 -1
- package/dist/host/pair.js +2 -0
- package/dist/host/scheduler.d.ts +5 -1
- package/dist/host/scheduler.js +7 -1
- package/dist/host/service.d.ts +5 -0
- package/dist/host/service.js +49 -7
- package/dist/index.js +5 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/host/install.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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.' };
|
package/dist/host/migrate.js
CHANGED
|
@@ -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 });
|
package/dist/host/pair.d.ts
CHANGED
|
@@ -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;
|
package/dist/host/scheduler.d.ts
CHANGED
|
@@ -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. */
|
package/dist/host/scheduler.js
CHANGED
|
@@ -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;
|
package/dist/host/service.d.ts
CHANGED
|
@@ -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>;
|
package/dist/host/service.js
CHANGED
|
@@ -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
|
-
:
|
|
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',
|