@phnx-labs/agents-cli 1.20.72 → 1.20.74

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 (234) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/doctor.js +21 -5
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/export.d.ts +11 -0
  16. package/dist/commands/export.js +215 -0
  17. package/dist/commands/feed.d.ts +13 -0
  18. package/dist/commands/feed.js +130 -29
  19. package/dist/commands/hq.d.ts +2 -0
  20. package/dist/commands/hq.js +58 -0
  21. package/dist/commands/import.js +51 -3
  22. package/dist/commands/lease.d.ts +19 -0
  23. package/dist/commands/lease.js +138 -11
  24. package/dist/commands/login.d.ts +2 -0
  25. package/dist/commands/login.js +123 -0
  26. package/dist/commands/logs.js +1 -1
  27. package/dist/commands/mine.d.ts +27 -0
  28. package/dist/commands/mine.js +207 -0
  29. package/dist/commands/monitors.js +79 -49
  30. package/dist/commands/plugins.js +1 -0
  31. package/dist/commands/profiles.js +158 -0
  32. package/dist/commands/repo.js +2 -60
  33. package/dist/commands/resources.d.ts +5 -0
  34. package/dist/commands/resources.js +95 -0
  35. package/dist/commands/routines.d.ts +2 -0
  36. package/dist/commands/routines.js +139 -37
  37. package/dist/commands/secrets.js +234 -47
  38. package/dist/commands/send.d.ts +15 -0
  39. package/dist/commands/send.js +62 -0
  40. package/dist/commands/sessions-tail.d.ts +1 -0
  41. package/dist/commands/sessions-tail.js +20 -9
  42. package/dist/commands/sessions.js +18 -3
  43. package/dist/commands/setup-mine.d.ts +18 -0
  44. package/dist/commands/setup-mine.js +106 -0
  45. package/dist/commands/setup-share.js +2 -2
  46. package/dist/commands/setup.js +9 -2
  47. package/dist/commands/share.d.ts +8 -1
  48. package/dist/commands/share.js +122 -37
  49. package/dist/commands/ssh.d.ts +9 -0
  50. package/dist/commands/ssh.js +196 -26
  51. package/dist/commands/sync.js +1 -1
  52. package/dist/commands/teams.js +11 -2
  53. package/dist/commands/versions.js +36 -5
  54. package/dist/commands/view.js +75 -27
  55. package/dist/index.js +80 -10
  56. package/dist/lib/activity.d.ts +124 -0
  57. package/dist/lib/activity.js +542 -0
  58. package/dist/lib/agents.d.ts +35 -0
  59. package/dist/lib/agents.js +79 -6
  60. package/dist/lib/artifact-actions.d.ts +1 -1
  61. package/dist/lib/artifact-actions.js +1 -1
  62. package/dist/lib/ask-classifier.d.ts +2 -1
  63. package/dist/lib/ask-classifier.js +3 -3
  64. package/dist/lib/auth-health.d.ts +32 -4
  65. package/dist/lib/auth-health.js +40 -1
  66. package/dist/lib/auto-pull.js +8 -1
  67. package/dist/lib/brand.d.ts +40 -0
  68. package/dist/lib/brand.js +122 -0
  69. package/dist/lib/browser/drivers/ssh.js +4 -7
  70. package/dist/lib/browser/service.js +6 -8
  71. package/dist/lib/channels/providers/index.d.ts +2 -0
  72. package/dist/lib/channels/providers/index.js +20 -0
  73. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  74. package/dist/lib/channels/providers/mailbox.js +26 -0
  75. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  76. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  77. package/dist/lib/channels/providers/rush.d.ts +6 -0
  78. package/dist/lib/channels/providers/rush.js +55 -0
  79. package/dist/lib/channels/registry.d.ts +42 -0
  80. package/dist/lib/channels/registry.js +20 -0
  81. package/dist/lib/channels/resolve.d.ts +11 -0
  82. package/dist/lib/channels/resolve.js +12 -0
  83. package/dist/lib/cli-resources.d.ts +18 -0
  84. package/dist/lib/cli-resources.js +53 -4
  85. package/dist/lib/cloud/codex.js +6 -3
  86. package/dist/lib/cloud/factory.d.ts +1 -0
  87. package/dist/lib/cloud/factory.js +10 -4
  88. package/dist/lib/cloud/rush.d.ts +3 -1
  89. package/dist/lib/cloud/rush.js +6 -1
  90. package/dist/lib/cloud/types.d.ts +2 -0
  91. package/dist/lib/codex-home.d.ts +35 -0
  92. package/dist/lib/codex-home.js +136 -0
  93. package/dist/lib/config-transfer.d.ts +16 -0
  94. package/dist/lib/config-transfer.js +56 -0
  95. package/dist/lib/crabbox/cli.d.ts +44 -0
  96. package/dist/lib/crabbox/cli.js +120 -20
  97. package/dist/lib/crabbox/lease.d.ts +52 -1
  98. package/dist/lib/crabbox/lease.js +117 -16
  99. package/dist/lib/crabbox/progress.d.ts +31 -0
  100. package/dist/lib/crabbox/progress.js +76 -0
  101. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  102. package/dist/lib/crabbox/runtimes.js +33 -8
  103. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  104. package/dist/lib/crabbox/setup-copy.js +127 -0
  105. package/dist/lib/daemon.d.ts +5 -5
  106. package/dist/lib/daemon.js +9 -9
  107. package/dist/lib/drive-sync.js +6 -3
  108. package/dist/lib/event-stream.d.ts +36 -0
  109. package/dist/lib/event-stream.js +68 -0
  110. package/dist/lib/events.d.ts +1 -1
  111. package/dist/lib/exec.d.ts +11 -0
  112. package/dist/lib/exec.js +23 -2
  113. package/dist/lib/export.d.ts +72 -0
  114. package/dist/lib/export.js +269 -0
  115. package/dist/lib/feed-policy.d.ts +1 -1
  116. package/dist/lib/feed-policy.js +14 -9
  117. package/dist/lib/feed-ranking.d.ts +32 -0
  118. package/dist/lib/feed-ranking.js +224 -0
  119. package/dist/lib/feed.d.ts +35 -2
  120. package/dist/lib/feed.js +66 -3
  121. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  122. package/dist/lib/fleet/auth-sync.js +92 -0
  123. package/dist/lib/fleet/remote-login.d.ts +187 -0
  124. package/dist/lib/fleet/remote-login.js +556 -0
  125. package/dist/lib/format.d.ts +30 -3
  126. package/dist/lib/format.js +34 -5
  127. package/dist/lib/fs-atomic.d.ts +7 -2
  128. package/dist/lib/fs-atomic.js +8 -12
  129. package/dist/lib/git.d.ts +16 -0
  130. package/dist/lib/git.js +79 -2
  131. package/dist/lib/heal.js +11 -3
  132. package/dist/lib/hosts/progress.d.ts +28 -10
  133. package/dist/lib/hosts/progress.js +79 -22
  134. package/dist/lib/hosts/remote-cmd.js +4 -0
  135. package/dist/lib/hq/floor.d.ts +87 -0
  136. package/dist/lib/hq/floor.js +226 -0
  137. package/dist/lib/import.d.ts +23 -0
  138. package/dist/lib/import.js +74 -2
  139. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  140. package/dist/lib/isolation-boundary-report.js +35 -0
  141. package/dist/lib/menubar/install-menubar.js +14 -20
  142. package/dist/lib/notify.d.ts +1 -0
  143. package/dist/lib/notify.js +3 -3
  144. package/dist/lib/open-url.d.ts +2 -0
  145. package/dist/lib/open-url.js +19 -0
  146. package/dist/lib/openclaw-keychain.d.ts +56 -0
  147. package/dist/lib/openclaw-keychain.js +236 -0
  148. package/dist/lib/overdue.js +10 -0
  149. package/dist/lib/permissions.d.ts +44 -1
  150. package/dist/lib/permissions.js +284 -7
  151. package/dist/lib/project-launch.d.ts +6 -12
  152. package/dist/lib/project-launch.js +13 -228
  153. package/dist/lib/project-resources.d.ts +7 -0
  154. package/dist/lib/project-resources.js +306 -0
  155. package/dist/lib/pty-client.d.ts +27 -0
  156. package/dist/lib/pty-client.js +136 -10
  157. package/dist/lib/refresh.js +14 -10
  158. package/dist/lib/resource-profiles.d.ts +26 -0
  159. package/dist/lib/resource-profiles.js +157 -0
  160. package/dist/lib/resources/permissions.js +7 -1
  161. package/dist/lib/resources/types.d.ts +1 -1
  162. package/dist/lib/resources.d.ts +1 -1
  163. package/dist/lib/resources.js +32 -3
  164. package/dist/lib/routines.d.ts +11 -0
  165. package/dist/lib/routines.js +56 -15
  166. package/dist/lib/runner.d.ts +1 -0
  167. package/dist/lib/runner.js +66 -5
  168. package/dist/lib/secrets/bundles.d.ts +16 -3
  169. package/dist/lib/secrets/bundles.js +206 -37
  170. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  171. package/dist/lib/secrets/icloud-import.js +9 -6
  172. package/dist/lib/secrets/mcp.js +1 -1
  173. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  174. package/dist/lib/secrets/vault-age-helper.js +34 -0
  175. package/dist/lib/secrets/vault.d.ts +49 -0
  176. package/dist/lib/secrets/vault.js +397 -0
  177. package/dist/lib/self-heal/checks/path.js +3 -1
  178. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  179. package/dist/lib/self-heal/checks/shims.js +19 -11
  180. package/dist/lib/session/cloud.d.ts +2 -2
  181. package/dist/lib/session/cloud.js +2 -2
  182. package/dist/lib/session/db.d.ts +4 -0
  183. package/dist/lib/session/db.js +44 -7
  184. package/dist/lib/session/discover.d.ts +2 -0
  185. package/dist/lib/session/discover.js +114 -3
  186. package/dist/lib/session/stream-render.d.ts +3 -0
  187. package/dist/lib/session/stream-render.js +130 -0
  188. package/dist/lib/session/types.d.ts +6 -0
  189. package/dist/lib/share/analytics.d.ts +13 -0
  190. package/dist/lib/share/analytics.js +45 -0
  191. package/dist/lib/share/config.d.ts +19 -5
  192. package/dist/lib/share/config.js +44 -8
  193. package/dist/lib/share/provision.d.ts +58 -6
  194. package/dist/lib/share/provision.js +97 -22
  195. package/dist/lib/share/publish.d.ts +36 -13
  196. package/dist/lib/share/publish.js +55 -8
  197. package/dist/lib/share/worker-template.js +83 -17
  198. package/dist/lib/shims.d.ts +35 -0
  199. package/dist/lib/shims.js +159 -13
  200. package/dist/lib/ssh-exec.d.ts +14 -0
  201. package/dist/lib/ssh-exec.js +57 -0
  202. package/dist/lib/staleness/detectors/hooks.js +25 -1
  203. package/dist/lib/staleness/detectors/permissions.js +66 -0
  204. package/dist/lib/staleness/detectors/workflows.js +20 -0
  205. package/dist/lib/staleness/writers/hooks.js +58 -4
  206. package/dist/lib/staleness/writers/permissions.js +2 -2
  207. package/dist/lib/staleness/writers/skills.js +1 -1
  208. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  209. package/dist/lib/staleness/writers/sources.js +68 -1
  210. package/dist/lib/star-nudge.d.ts +45 -0
  211. package/dist/lib/star-nudge.js +91 -0
  212. package/dist/lib/startup/command-registry.d.ts +7 -1
  213. package/dist/lib/startup/command-registry.js +19 -3
  214. package/dist/lib/state.d.ts +2 -0
  215. package/dist/lib/state.js +17 -4
  216. package/dist/lib/subagents-registry.d.ts +2 -0
  217. package/dist/lib/subagents-registry.js +3 -0
  218. package/dist/lib/teams/parsers.js +214 -6
  219. package/dist/lib/teams/supervisor.d.ts +7 -0
  220. package/dist/lib/teams/supervisor.js +2 -1
  221. package/dist/lib/template.d.ts +1 -1
  222. package/dist/lib/template.js +1 -1
  223. package/dist/lib/triggers/webhook.js +36 -3
  224. package/dist/lib/types.d.ts +84 -0
  225. package/dist/lib/uninstall.js +1 -45
  226. package/dist/lib/version.d.ts +40 -0
  227. package/dist/lib/version.js +94 -16
  228. package/dist/lib/versions.d.ts +24 -0
  229. package/dist/lib/versions.js +269 -78
  230. package/dist/lib/workflows.d.ts +2 -0
  231. package/dist/lib/workflows.js +88 -0
  232. package/package.json +2 -1
  233. package/dist/commands/daemon.d.ts +0 -10
  234. package/dist/commands/daemon.js +0 -121
@@ -41,9 +41,9 @@ export function ensureLockTarget(filePath, initialContent = '', dirMode) {
41
41
  * Writes content to filePath via a temp file + rename so readers never see a
42
42
  * partial write. On POSIX, rename(2) is atomic.
43
43
  */
44
- export function atomicWriteFileSync(filePath, content) {
44
+ export function atomicWriteFileSync(filePath, content, options = 'utf-8') {
45
45
  const tmpPath = `${filePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`;
46
- fs.writeFileSync(tmpPath, content, 'utf-8');
46
+ fs.writeFileSync(tmpPath, content, options);
47
47
  try {
48
48
  fs.renameSync(tmpPath, filePath);
49
49
  }
@@ -55,19 +55,15 @@ export function atomicWriteFileSync(filePath, content) {
55
55
  throw err;
56
56
  }
57
57
  }
58
- /**
59
- * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
60
- * releases the lock. Retries with capped linear back-off until either the lock
61
- * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
62
- * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
63
- */
64
- export function withFileLock(filePath, fn) {
58
+ export function withFileLock(filePath, fn, opts = {}) {
65
59
  let release = null;
66
60
  let lastError;
67
- const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
61
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
62
+ const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
63
+ const deadline = Date.now() + acquireTimeoutMs;
68
64
  for (let attempt = 0;; attempt++) {
69
65
  try {
70
- release = lockfile.lockSync(filePath, { stale: LOCK_STALE_MS });
66
+ release = lockfile.lockSync(filePath, { stale: staleMs });
71
67
  break;
72
68
  }
73
69
  catch (err) {
@@ -80,7 +76,7 @@ export function withFileLock(filePath, fn) {
80
76
  }
81
77
  if (!release) {
82
78
  const message = lastError instanceof Error ? lastError.message : String(lastError);
83
- throw new Error(`Could not acquire lock for ${filePath} after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${message}`);
79
+ throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
84
80
  }
85
81
  try {
86
82
  return fn();
package/dist/lib/git.d.ts CHANGED
@@ -91,6 +91,22 @@ export declare function getRepoCommit(repoPath: string): Promise<string>;
91
91
  * Returns null if gh is not installed or user is not authenticated.
92
92
  */
93
93
  export declare function getGitHubUsername(): Promise<string | null>;
94
+ /**
95
+ * Resolve the GitHub username synchronously for `agents share`. Order:
96
+ * 1. `AGENTS_SHARE_GITHUB_USER` env override
97
+ * 2. `git config --global github.user`
98
+ * Falls back to null so callers can decide whether to require auth or proceed
99
+ * without a namespace.
100
+ */
101
+ export declare function resolveGitHubUsernameSync(): string | null;
102
+ /**
103
+ * Resolve the GitHub username asynchronously. Order:
104
+ * 1. `AGENTS_SHARE_GITHUB_USER` env override
105
+ * 2. `gh api user --jq ".login"`
106
+ * 3. `git config --global github.user`
107
+ * Returns null if none succeed.
108
+ */
109
+ export declare function resolveGitHubUsername(): Promise<string | null>;
94
110
  /**
95
111
  * Get the remote URL for origin in a git repo.
96
112
  */
package/dist/lib/git.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * source parsing for GitHub shorthand, SSH, HTTPS, and local paths.
7
7
  */
8
8
  import simpleGit from 'simple-git';
9
+ import { execFileSync } from 'node:child_process';
9
10
  import * as fs from 'fs';
10
11
  import * as os from 'os';
11
12
  import * as path from 'path';
@@ -350,6 +351,47 @@ export async function getGitHubUsername() {
350
351
  return null;
351
352
  }
352
353
  }
354
+ const GITHUB_USER_ENV = 'AGENTS_SHARE_GITHUB_USER';
355
+ /** Best-effort sync read of `github.user` from git config. */
356
+ function readGitConfigUser() {
357
+ try {
358
+ return execFileSync('git', ['config', '--global', 'github.user'], { stdio: ['ignore', 'pipe', 'ignore'] })
359
+ .toString()
360
+ .trim() || null;
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ }
366
+ /**
367
+ * Resolve the GitHub username synchronously for `agents share`. Order:
368
+ * 1. `AGENTS_SHARE_GITHUB_USER` env override
369
+ * 2. `git config --global github.user`
370
+ * Falls back to null so callers can decide whether to require auth or proceed
371
+ * without a namespace.
372
+ */
373
+ export function resolveGitHubUsernameSync() {
374
+ const env = process.env[GITHUB_USER_ENV]?.trim();
375
+ if (env)
376
+ return env;
377
+ return readGitConfigUser();
378
+ }
379
+ /**
380
+ * Resolve the GitHub username asynchronously. Order:
381
+ * 1. `AGENTS_SHARE_GITHUB_USER` env override
382
+ * 2. `gh api user --jq ".login"`
383
+ * 3. `git config --global github.user`
384
+ * Returns null if none succeed.
385
+ */
386
+ export async function resolveGitHubUsername() {
387
+ const env = process.env[GITHUB_USER_ENV]?.trim();
388
+ if (env)
389
+ return env;
390
+ const gh = await getGitHubUsername();
391
+ if (gh)
392
+ return gh;
393
+ return readGitConfigUser();
394
+ }
353
395
  /**
354
396
  * Get the remote URL for origin in a git repo.
355
397
  */
@@ -779,12 +821,47 @@ export async function pullRepo(dir) {
779
821
  return {
780
822
  success: false,
781
823
  commit: '',
782
- error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
824
+ error: `Blocked by local changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
783
825
  };
784
826
  }
785
827
  const branch = status.current || 'main';
786
828
  await git.fetch('origin');
787
- await git.pull('origin', branch, { '--rebase': 'true' });
829
+ // Resolve the upstream ref to fast-forward against. Prefer the local
830
+ // branch's tracking config; otherwise ask origin for its default branch.
831
+ let tracking = status.tracking;
832
+ if (!tracking) {
833
+ try {
834
+ await git.raw(['remote', 'set-head', 'origin', '--auto']);
835
+ const sym = await git.raw(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
836
+ tracking = sym.trim();
837
+ }
838
+ catch {
839
+ tracking = `origin/${branch}`;
840
+ }
841
+ }
842
+ const localRef = await git.revparse(['HEAD']);
843
+ const remoteRef = await git.revparse([tracking]).catch(() => null);
844
+ if (!remoteRef) {
845
+ return { success: false, commit: '', error: `Could not resolve upstream ref ${tracking}` };
846
+ }
847
+ if (localRef === remoteRef) {
848
+ const log = await git.log({ maxCount: 1 });
849
+ return {
850
+ success: true,
851
+ commit: log.latest?.hash.slice(0, 8) || 'unknown',
852
+ branch,
853
+ };
854
+ }
855
+ try {
856
+ await git.merge(['--ff-only', tracking]);
857
+ }
858
+ catch {
859
+ return {
860
+ success: false,
861
+ commit: '',
862
+ error: `Blocked by local commits. Push or reset them before pulling.\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}`,
863
+ };
864
+ }
788
865
  installGithooksSymlinks(dir);
789
866
  const log = await git.log({ maxCount: 1 });
790
867
  return {
package/dist/lib/heal.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * `agents prune cleanup`, so a heal pass can never lose work.
24
24
  */
25
25
  import { ALL_AGENT_IDS } from './agents.js';
26
- import { syncResourcesToVersion, listInstalledVersions, getVersionHomePath, getActuallySyncedResources, compareVersions, } from './versions.js';
26
+ import { syncResourcesToVersion, listInstalledVersions, isVersionIsolated, getVersionHomePath, getActuallySyncedResources, compareVersions, } from './versions.js';
27
27
  import { diffVersionResources, } from './doctor-diff.js';
28
28
  import { discoverPlugins, updatePlugin, readPluginSourceInfo, getUpstreamManifestVersion, } from './plugins.js';
29
29
  import { repairPluginManifestFile } from './plugin-marketplace.js';
@@ -266,9 +266,17 @@ export async function heal(opts) {
266
266
  ...repairedManifests.map((r) => r.plugin),
267
267
  ...refreshed.map((r) => r.plugin),
268
268
  ]);
269
+ // Isolated copies are excluded from every SWEEP. `agents add --isolated`
270
+ // promises "no settings carry-over, no resource sync", and this engine runs
271
+ // unattended from the daemon (~30s after start, then every ~6h) — a sweep that
272
+ // walked into an isolated home would quietly refill it with shared commands,
273
+ // skills, hooks, MCP config and permissions. Explicitly NAMED versions
274
+ // (`agents doctor <agent>@<version> --fix`) are honoured as-is: naming the
275
+ // version is the operator's consent.
276
+ const sweep = (a) => listInstalledVersions(a).filter((v) => !isVersionIsolated(a, v));
269
277
  const targets = opts.agent
270
- ? [{ agent: opts.agent, versions: opts.versions ?? listInstalledVersions(opts.agent) }]
271
- : ALL_AGENT_IDS.map((a) => ({ agent: a, versions: listInstalledVersions(a) }));
278
+ ? [{ agent: opts.agent, versions: opts.versions ?? sweep(opts.agent) }]
279
+ : ALL_AGENT_IDS.map((a) => ({ agent: a, versions: sweep(a) }));
272
280
  const versions = [];
273
281
  for (const t of targets) {
274
282
  for (const v of t.versions) {
@@ -1,17 +1,15 @@
1
1
  /**
2
- * Follow a dispatched host run by offset-tailing its remote log.
2
+ * Follow a dispatched host run by streaming its remote log.
3
3
  *
4
4
  * The run writes combined output to a log file on the host and its exit code to
5
- * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
- * a dropped connection resumes from the saved offset) and finish when `.exit`
7
- * appears. Rich transcript-parser rendering is a fast-follow.
5
+ * a sibling `.exit` file. The primary path opens one long-lived
6
+ * `ssh ... tail -f` stream (durable, offset-tracked a dropped connection
7
+ * reconnects from the saved offset) and finishes when the remote-side watcher
8
+ * emits the exit sentinel. Rich transcript-parser rendering is a fast-follow.
8
9
  *
9
- * Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
10
- * bytes, a per-task sentinel, then the exit-file contents half the process +
11
- * handshake cost of the old tail-then-cat pair. It rides the default control
12
- * socket (multiplex on) that the launch opened, and eases the poll interval off
13
- * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
- * longer spawns thousands of ssh processes per hour on the laptop.
10
+ * Efficiency: a healthy follow holds exactly one ssh process/socket for the
11
+ * whole run instead of spawning once per poll cycle. If the connection drops,
12
+ * the next stream resumes at the byte offset already flushed locally.
15
13
  */
16
14
  /**
17
15
  * Cap for the LOCAL mirror of a distributed teammate's remote log. `followHostTask`
@@ -102,6 +100,26 @@ export declare function fetchProgress(target: string, opts: {
102
100
  logChunk: Buffer;
103
101
  exit: string;
104
102
  } | null;
103
+ /**
104
+ * Remote shell for the persistent follow stream.
105
+ *
106
+ * Protocol: stream `tail -c +<offset+1> -f <log>` as raw stdout until `.exit`
107
+ * becomes non-empty, give tail one more polling interval to flush final bytes,
108
+ * stop it, then print the same per-task sentinel and `cat` the exit file on
109
+ * stderr. Keeping the terminal frame off stdout lets log bytes pass through
110
+ * live without buffering and without confusing agent output for our marker.
111
+ * `remoteLog`/`remoteExit` are dispatch-generated `$HOME` paths with safe hex
112
+ * basenames (or absolute paths in localhost integration tests), so they stay
113
+ * unquoted to preserve `$HOME` expansion just like `fetchProgress`.
114
+ */
115
+ export declare function buildStreamingFollowCommand(opts: {
116
+ remoteLog: string;
117
+ remoteExit: string;
118
+ taskId: string;
119
+ offset: number;
120
+ }): string;
121
+ /** Extract the remote watcher frame from stderr, if the stream ended normally. */
122
+ export declare function parseStreamingExitFrame(stderr: Buffer, taskId: string): Buffer | null;
105
123
  /**
106
124
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
107
125
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
@@ -1,20 +1,18 @@
1
1
  /**
2
- * Follow a dispatched host run by offset-tailing its remote log.
2
+ * Follow a dispatched host run by streaming its remote log.
3
3
  *
4
4
  * The run writes combined output to a log file on the host and its exit code to
5
- * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
- * a dropped connection resumes from the saved offset) and finish when `.exit`
7
- * appears. Rich transcript-parser rendering is a fast-follow.
5
+ * a sibling `.exit` file. The primary path opens one long-lived
6
+ * `ssh ... tail -f` stream (durable, offset-tracked a dropped connection
7
+ * reconnects from the saved offset) and finishes when the remote-side watcher
8
+ * emits the exit sentinel. Rich transcript-parser rendering is a fast-follow.
8
9
  *
9
- * Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
10
- * bytes, a per-task sentinel, then the exit-file contents half the process +
11
- * handshake cost of the old tail-then-cat pair. It rides the default control
12
- * socket (multiplex on) that the launch opened, and eases the poll interval off
13
- * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
- * longer spawns thousands of ssh processes per hour on the laptop.
10
+ * Efficiency: a healthy follow holds exactly one ssh process/socket for the
11
+ * whole run instead of spawning once per poll cycle. If the connection drops,
12
+ * the next stream resumes at the byte offset already flushed locally.
15
13
  */
16
14
  import * as fs from 'fs';
17
- import { sshExec, sshExecRaw } from '../ssh-exec.js';
15
+ import { sshExec, sshExecRaw, sshExecRawStream } from '../ssh-exec.js';
18
16
  import { localLogPath } from './tasks.js';
19
17
  function sleep(ms) {
20
18
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -54,6 +52,7 @@ export function pullRemoteLogDelta(target, opts) {
54
52
  return null; // ssh itself failed / timed out
55
53
  return { bytes: res.stdout, newOffset: opts.offset + res.stdout.length };
56
54
  }
55
+ const STREAM_EXIT_POLL_SECONDS = 1;
57
56
  /**
58
57
  * Build the per-task sentinel that separates the log tail from the exit-file
59
58
  * contents in one combined fetch. The task id (8 hex chars) makes collision with
@@ -116,6 +115,47 @@ export function fetchProgress(target, opts) {
116
115
  return null;
117
116
  return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
118
117
  }
118
+ /**
119
+ * Remote shell for the persistent follow stream.
120
+ *
121
+ * Protocol: stream `tail -c +<offset+1> -f <log>` as raw stdout until `.exit`
122
+ * becomes non-empty, give tail one more polling interval to flush final bytes,
123
+ * stop it, then print the same per-task sentinel and `cat` the exit file on
124
+ * stderr. Keeping the terminal frame off stdout lets log bytes pass through
125
+ * live without buffering and without confusing agent output for our marker.
126
+ * `remoteLog`/`remoteExit` are dispatch-generated `$HOME` paths with safe hex
127
+ * basenames (or absolute paths in localhost integration tests), so they stay
128
+ * unquoted to preserve `$HOME` expansion just like `fetchProgress`.
129
+ */
130
+ export function buildStreamingFollowCommand(opts) {
131
+ const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
132
+ return [
133
+ 'set +e',
134
+ 'tail_pid=',
135
+ 'cleanup() { if [ -n "$tail_pid" ]; then kill "$tail_pid" 2>/dev/null || true; wait "$tail_pid" 2>/dev/null || true; fi; }',
136
+ 'trap cleanup EXIT HUP INT TERM',
137
+ `while [ ! -e ${opts.remoteLog} ] && [ ! -s ${opts.remoteExit} ]; do sleep ${STREAM_EXIT_POLL_SECONDS}; done`,
138
+ `tail -c +${opts.offset + 1} -f ${opts.remoteLog} 2>/dev/null &`,
139
+ 'tail_pid=$!',
140
+ `while [ ! -s ${opts.remoteExit} ]; do`,
141
+ ' if ! kill -0 "$tail_pid" 2>/dev/null; then wait "$tail_pid" 2>/dev/null; exit 86; fi',
142
+ ` sleep ${STREAM_EXIT_POLL_SECONDS}`,
143
+ 'done',
144
+ `sleep ${STREAM_EXIT_POLL_SECONDS}`,
145
+ 'cleanup',
146
+ 'tail_pid=',
147
+ `printf '${printfArg}' >&2`,
148
+ `cat ${opts.remoteExit} >&2 2>/dev/null`,
149
+ ].join('\n');
150
+ }
151
+ /** Extract the remote watcher frame from stderr, if the stream ended normally. */
152
+ export function parseStreamingExitFrame(stderr, taskId) {
153
+ const marker = Buffer.from(exitMarker(taskId), 'utf8');
154
+ const idx = stderr.lastIndexOf(marker);
155
+ if (idx === -1)
156
+ return null;
157
+ return stderr.subarray(idx + marker.length);
158
+ }
119
159
  /**
120
160
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
121
161
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
@@ -169,23 +209,40 @@ export async function followHostTask(target, opts) {
169
209
  return true;
170
210
  };
171
211
  for (;;) {
172
- const r = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
173
- const gotOutput = r ? flush(r.logChunk) : false;
174
- if (r && r.exit.trim() !== '') {
175
- // Finished — one final fetch catches bytes written between our tail and
176
- // the exit file appearing.
177
- const tail = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
178
- if (tail)
179
- flush(tail.logChunk);
180
- const code = parseInt(r.exit.trim(), 10);
212
+ const remaining = deadline - Date.now();
213
+ if (remaining <= 0) {
214
+ process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
215
+ return -1;
216
+ }
217
+ const abort = new AbortController();
218
+ const timer = setTimeout(() => abort.abort(), remaining);
219
+ let gotOutput = false;
220
+ let exitFrame = null;
221
+ try {
222
+ const stream = await sshExecRawStream(target, buildStreamingFollowCommand({ remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset }), {
223
+ timeoutMs: remaining,
224
+ signal: abort.signal,
225
+ multiplex: true,
226
+ onStdout: (chunk) => { gotOutput = flush(chunk) || gotOutput; },
227
+ });
228
+ exitFrame = parseStreamingExitFrame(stream.stderr, opts.taskId);
229
+ }
230
+ finally {
231
+ clearTimeout(timer);
232
+ }
233
+ if (exitFrame) {
234
+ const code = parseInt(exitFrame.toString('utf8').trim(), 10);
181
235
  return Number.isFinite(code) ? code : 0;
182
236
  }
237
+ // SSH dropped before the remote watcher emitted the exit sentinel. Stdout
238
+ // chunks were flushed as they arrived, so reconnect from the advanced offset.
239
+ // If no bytes arrived, back off like the old idle poll.
183
240
  if (Date.now() > deadline) {
184
241
  process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
185
242
  return -1;
186
243
  }
187
- // Fast while output flows; ease toward maxMs when idle so a quiet job isn't
188
- // polled needlessly. New output snaps the cadence back to fast.
244
+ // Reconnect quickly while output flows; ease toward maxMs when the stream
245
+ // drops without bytes so a flapping idle host does not spin ssh processes.
189
246
  waitMs = gotOutput ? fastMs : Math.min(maxMs, Math.round(waitMs * 1.5));
190
247
  await sleep(waitMs);
191
248
  }
@@ -108,7 +108,11 @@ export const RUN_OPTION_FORWARDING = {
108
108
  any: 'local-only',
109
109
  follow: 'local-only',
110
110
  lease: 'local-only',
111
+ box: 'local-only',
111
112
  keepBox: 'local-only',
113
+ reuse: 'local-only', // reuse-picker choice for --lease; the lease path is always local
114
+ bare: 'local-only', // skips the local setup-copy push; lease-only concern
115
+ tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
112
116
  copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
113
117
  authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
114
118
  };
@@ -0,0 +1,87 @@
1
+ import type { ActiveSession } from '../session/active.js';
2
+ import type { OpenBlock } from '../feed.js';
3
+ import type { AgentStatusDetail, TaskInfo } from '../teams/api.js';
4
+ export type HqRoomKind = 'team' | 'machine' | 'lobby';
5
+ export type HqAgentMood = 'working' | 'waiting' | 'blocked' | 'celebrating' | 'idle';
6
+ export type HqAmbientKind = 'needs_input' | 'error' | 'pr' | 'team_active' | 'idle_scene';
7
+ export interface HqAction {
8
+ id: string;
9
+ label: string;
10
+ command: string[];
11
+ target: {
12
+ kind: 'agent' | 'room' | 'floor';
13
+ id: string;
14
+ };
15
+ destructive?: boolean;
16
+ input?: Array<{
17
+ name: string;
18
+ label: string;
19
+ placeholder?: string;
20
+ required?: boolean;
21
+ }>;
22
+ }
23
+ export interface HqRoom {
24
+ id: string;
25
+ kind: HqRoomKind;
26
+ name: string;
27
+ machine?: string;
28
+ team?: string;
29
+ counts: {
30
+ agents: number;
31
+ needsInput: number;
32
+ running: number;
33
+ failed: number;
34
+ };
35
+ actions: HqAction[];
36
+ }
37
+ export interface HqAgent {
38
+ id: string;
39
+ label: string;
40
+ agent: string;
41
+ roomId: string;
42
+ mood: HqAgentMood;
43
+ status: string;
44
+ activity?: string;
45
+ preview?: string;
46
+ machine?: string;
47
+ team?: string;
48
+ teammate?: string;
49
+ sessionId?: string;
50
+ mailboxId?: string;
51
+ pid?: number;
52
+ prUrl?: string;
53
+ ticketId?: string;
54
+ actions: HqAction[];
55
+ }
56
+ export interface HqAmbientEvent {
57
+ id: string;
58
+ kind: HqAmbientKind;
59
+ roomId: string;
60
+ agentId?: string;
61
+ label: string;
62
+ intensity: 'low' | 'medium' | 'high';
63
+ }
64
+ export interface HqFloorSnapshot {
65
+ version: 1;
66
+ generatedAt: string;
67
+ counters: {
68
+ rooms: number;
69
+ agents: number;
70
+ teams: number;
71
+ needsInput: number;
72
+ failed: number;
73
+ prs: number;
74
+ };
75
+ rooms: HqRoom[];
76
+ agents: HqAgent[];
77
+ ambientEvents: HqAmbientEvent[];
78
+ actions: HqAction[];
79
+ }
80
+ export interface BuildHqFloorInput {
81
+ generatedAt?: Date;
82
+ sessions: ActiveSession[];
83
+ teams: TaskInfo[];
84
+ teammatesByTeam: Map<string, AgentStatusDetail[]>;
85
+ blocks: OpenBlock[];
86
+ }
87
+ export declare function buildHqFloor(input: BuildHqFloorInput): HqFloorSnapshot;