@phnx-labs/agents-cli 1.20.34 → 1.20.35

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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -538,14 +538,42 @@ export function buildDetachedDaemonEnv(baseEnv = process.env) {
538
538
  }
539
539
  return env;
540
540
  }
541
- function startDetached() {
542
- const agentsBin = getAgentsBinPath();
543
- const logPath = getLogPath();
541
+ /**
542
+ * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
543
+ * exact form that works under a direct `daemon _run`.
544
+ *
545
+ * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
546
+ * argument rather than executing the entry path directly. Executing the `.js`
547
+ * path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
548
+ * `.js`/shim directly at all — it gets launched through a transient
549
+ * console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
550
+ * closes its console, and the detached daemon sharing that console receives a
551
+ * console-close event that trips its shutdown handler — the daemon comes up,
552
+ * binds the browser IPC socket, then tears itself down ~36ms later (#556).
553
+ * Going through `process.execPath` means a real PE/binary is spawned with
554
+ * `detached: true` and no console, so nothing signals the daemon after launch.
555
+ *
556
+ * When the entry isn't a JS file (e.g. a native launcher resolved via
557
+ * `which agents`), run it directly — it owns its own runtime resolution.
558
+ */
559
+ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
560
+ if (/\.(c|m)?js$/.test(agentsBin)) {
561
+ return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
562
+ }
563
+ return { command: agentsBin, args: ['daemon', '_run'] };
564
+ }
565
+ export function startDetached(opts = {}) {
566
+ const agentsBin = opts.agentsBin ?? getAgentsBinPath();
567
+ const logPath = opts.logPath ?? getLogPath();
544
568
  const logFd = fs.openSync(logPath, 'a');
545
- const child = spawn(agentsBin, ['daemon', '_run'], {
569
+ const { command, args } = getDaemonLaunch(agentsBin);
570
+ const child = spawn(command, args, {
546
571
  stdio: ['ignore', logFd, logFd],
547
572
  detached: true,
548
- env: buildDetachedDaemonEnv(),
573
+ // No console window on Windows — a hidden, consoleless process can't be
574
+ // torn down by a console-close event when its launcher exits (#556).
575
+ windowsHide: true,
576
+ env: opts.env ?? buildDetachedDaemonEnv(),
549
577
  });
550
578
  child.unref();
551
579
  fs.closeSync(logFd);
@@ -11,8 +11,9 @@ export declare function sshTargetFor(device: DeviceProfile): string;
11
11
  /**
12
12
  * Wrap a remote command for the device's shell. Windows devices speak
13
13
  * PowerShell, so a bare command is run through `powershell -NoProfile
14
- * -Command`; POSIX devices get the command verbatim (the remote login shell
15
- * parses it). Returns undefined when no command was given (interactive login).
14
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
15
+ * shell parses it). Returns undefined when no command was given (interactive
16
+ * login).
16
17
  */
17
18
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
18
19
  /**
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { encodePwshBase64 } from '../pwsh.js';
19
20
  import { getCacheDir } from '../state.js';
20
21
  import { hostNameFor } from './ssh-config.js';
21
22
  /** Env var the askpass shim reads to know which bundle holds the password. */
@@ -38,15 +39,16 @@ export function sshTargetFor(device) {
38
39
  /**
39
40
  * Wrap a remote command for the device's shell. Windows devices speak
40
41
  * PowerShell, so a bare command is run through `powershell -NoProfile
41
- * -Command`; POSIX devices get the command verbatim (the remote login shell
42
- * parses it). Returns undefined when no command was given (interactive login).
42
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
43
+ * shell parses it). Returns undefined when no command was given (interactive
44
+ * login).
43
45
  */
44
46
  export function wrapRemoteCommand(device, cmd) {
45
47
  if (cmd.length === 0)
46
48
  return undefined;
47
49
  const joined = cmd.join(' ');
48
50
  if (device.shell === 'powershell') {
49
- return `powershell -NoProfile -Command ${shellQuote(joined)}`;
51
+ return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
50
52
  }
51
53
  return joined;
52
54
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Centralized event logging for agents-cli.
3
3
  *
4
- * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
4
+ * Structured JSONL logs at ~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl
5
5
  * with automatic daily rotation and rich metadata for debugging/auditing.
6
6
  *
7
7
  * Features:
@@ -11,7 +11,7 @@
11
11
  * - Permissions: logs dir is 0700, files are 0600 (owner-only)
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
14
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'resource.sync' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
14
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'resource.sync' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
15
15
  export interface EventMeta {
16
16
  ts: string;
17
17
  tz: string;
@@ -22,12 +22,18 @@ export interface EventMeta {
22
22
  pid: number;
23
23
  ppid: number;
24
24
  event: EventType;
25
+ osUser: string;
26
+ transport: 'local' | 'ssh';
27
+ sshClientIp?: string;
25
28
  }
26
29
  export interface EventPayload {
27
30
  agent?: string;
28
31
  version?: string;
29
32
  sessionId?: string;
30
33
  cwd?: string;
34
+ /** Top-level command group, e.g. 'teams', 'secrets' — the audit filter key. */
35
+ module?: string;
36
+ /** Full command path, e.g. 'teams create', 'secrets get'. */
31
37
  command?: string;
32
38
  args?: string[];
33
39
  input?: string;
@@ -155,6 +161,7 @@ export declare function query(options: {
155
161
  eventTypes?: EventType[];
156
162
  agent?: string;
157
163
  command?: string;
164
+ module?: string;
158
165
  limit?: number;
159
166
  }): EventRecord[];
160
167
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Centralized event logging for agents-cli.
3
3
  *
4
- * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
4
+ * Structured JSONL logs at ~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl
5
5
  * with automatic daily rotation and rich metadata for debugging/auditing.
6
6
  *
7
7
  * Features:
@@ -15,6 +15,7 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
+ import { parseSshConnection } from './session/provenance.js';
18
19
  // ─── Constants ────────────────────────────────────────────────────────────────
19
20
  // Logs live under the cache bucket — they're regenerable telemetry.
20
21
  const LOGS_DIR = path.join(os.homedir(), '.agents', '.cache', 'logs');
@@ -130,6 +131,30 @@ function truncatePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
130
131
  }
131
132
  return result;
132
133
  }
134
+ /**
135
+ * Who is running this process and from where. Derived once per process from the
136
+ * OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
137
+ * cached — provenance can't change mid-process, so every emit() pays for it once.
138
+ */
139
+ let _origin;
140
+ function auditOrigin() {
141
+ if (_origin)
142
+ return _origin;
143
+ let osUser = 'unknown';
144
+ try {
145
+ osUser = os.userInfo().username;
146
+ }
147
+ catch {
148
+ // Container/edge cases where the uid has no passwd entry.
149
+ }
150
+ const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
151
+ _origin = {
152
+ osUser,
153
+ transport: ssh ? 'ssh' : 'local',
154
+ ...(ssh ? { sshClientIp: ssh.clientIp } : {}),
155
+ };
156
+ return _origin;
157
+ }
133
158
  // ─── Core API ─────────────────────────────────────────────────────────────────
134
159
  /**
135
160
  * Emit a structured event to the daily log file.
@@ -152,23 +177,33 @@ export function emit(event, payload = {}) {
152
177
  pid: process.pid,
153
178
  ppid: process.ppid,
154
179
  event,
180
+ ...auditOrigin(),
155
181
  ...truncatePayload(payload),
156
182
  };
157
183
  const line = JSON.stringify(record) + '\n';
158
184
  const logPath = getLogFilePath();
185
+ const isNew = !fs.existsSync(logPath);
159
186
  fs.appendFileSync(logPath, line, { mode: FILE_MODE });
160
- // Ensure file permissions (appendFileSync doesn't respect mode on existing files)
161
- try {
162
- fs.chmodSync(logPath, FILE_MODE);
163
- }
164
- catch {
165
- // May fail if not owner
187
+ // appendFileSync's mode only applies when it CREATES the file, so we chmod
188
+ // to guarantee 0600 — but only on first write to a given path this process.
189
+ // command.start/end fire on every invocation; chmod-per-append would double
190
+ // the syscalls on the hot path for no gain (perms don't drift mid-run).
191
+ if (isNew || logPath !== _chmoddedPath) {
192
+ _chmoddedPath = logPath;
193
+ try {
194
+ fs.chmodSync(logPath, FILE_MODE);
195
+ }
196
+ catch {
197
+ // May fail if not owner
198
+ }
166
199
  }
167
200
  }
168
201
  catch {
169
202
  // Silent failure - logging should never break the CLI
170
203
  }
171
204
  }
205
+ /** Last log path this process chmod'd — avoids a redundant chmod per append. */
206
+ let _chmoddedPath;
172
207
  /**
173
208
  * Convenience wrapper for timed operations.
174
209
  * Returns a function to call when the operation completes.
@@ -402,7 +437,7 @@ export function maybeRotate() {
402
437
  * @returns Array of event records
403
438
  */
404
439
  export function query(options) {
405
- const { startDate, endDate = new Date(), eventTypes, agent, command, limit } = options;
440
+ const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
406
441
  const results = [];
407
442
  if (!fs.existsSync(LOGS_DIR))
408
443
  return results;
@@ -410,26 +445,50 @@ export function query(options) {
410
445
  .filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
411
446
  .sort()
412
447
  .reverse();
448
+ // Coarse file skip works on whole days, so floor the bounds to midnight —
449
+ // otherwise a sub-day window (`--since 2h` at 15:00 → startDate 13:00) would
450
+ // drop *today's* file, whose date stamps to 00:00. Precise filtering below is
451
+ // per-record on `ts`.
452
+ const startDay = startDate
453
+ ? new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
454
+ : undefined;
455
+ const endDay = endDate
456
+ ? new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
457
+ : undefined;
458
+ const startMs = startDate?.getTime();
459
+ const endMs = endDate?.getTime();
413
460
  for (const file of files) {
414
461
  const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
415
462
  if (!match)
416
463
  continue;
417
464
  const [, yyyy, mm, dd] = match;
418
465
  const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
419
- if (startDate && fileDate < startDate)
466
+ if (startDay && fileDate < startDay)
420
467
  continue;
421
- if (endDate && fileDate > endDate)
468
+ if (endDay && fileDate > endDay)
422
469
  continue;
423
470
  const content = fs.readFileSync(path.join(LOGS_DIR, file), 'utf-8');
424
471
  const lines = content.trim().split('\n').filter(Boolean);
425
472
  for (const line of lines.reverse()) {
426
473
  try {
427
474
  const record = JSON.parse(line);
475
+ // Precise per-record window — the file skip above is day-granular only.
476
+ const recMs = Date.parse(record.ts);
477
+ if (startMs !== undefined && !isNaN(recMs) && recMs < startMs)
478
+ continue;
479
+ if (endMs !== undefined && !isNaN(recMs) && recMs > endMs)
480
+ continue;
428
481
  if (eventTypes && !eventTypes.includes(record.event))
429
482
  continue;
430
483
  if (agent && record.agent !== agent)
431
484
  continue;
432
- if (command && record.command !== command)
485
+ // `--command` matches by path prefix on a word boundary, so a coarse
486
+ // "teams" catches "teams create"/"teams remove" while an exact
487
+ // "teams create" stays exact.
488
+ if (command && record.command !== command &&
489
+ !(typeof record.command === 'string' && record.command.startsWith(command + ' ')))
490
+ continue;
491
+ if (module && record.module !== module)
433
492
  continue;
434
493
  results.push(record);
435
494
  if (limit && results.length >= limit) {
package/dist/lib/exec.js CHANGED
@@ -16,6 +16,7 @@ import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
+ import { writePidSessionEntry } from './session/pid-registry.js';
19
20
  /**
20
21
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
21
22
  *
@@ -722,6 +723,15 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
722
723
  * unbuffered.
723
724
  */
724
725
  async function spawnAgent(options) {
726
+ // Assign a known session id up front for agents that accept one, so the
727
+ // launcher can record an EXACT pid -> session mapping (see pid-registry) —
728
+ // otherwise the headless `ag sessions --active` path can only guess
729
+ // "newest .jsonl in the cwd" and collapses co-located agents onto one row.
730
+ // Claude: `--session-id <uuid>` CREATES the session with that id (wired in
731
+ // buildExecCommand). Skip on resume — the id is the one being resumed.
732
+ if (options.agent === 'claude' && !options.resume && !options.sessionId) {
733
+ options = { ...options, sessionId: randomUUID() };
734
+ }
725
735
  const cmd = buildExecCommand(options);
726
736
  const [executable, ...args] = cmd;
727
737
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
@@ -770,6 +780,18 @@ async function spawnAgent(options) {
770
780
  env: buildExecEnv(options),
771
781
  shell: useShell,
772
782
  });
783
+ // Record this launch so `ag sessions --active` can map the pid to its exact
784
+ // session (sessionId is set for Claude above) instead of guessing the newest
785
+ // .jsonl in the cwd — the collapse that made co-located agents indistinguishable.
786
+ // Best-effort: pruned when the pid dies; a failed write just degrades to the heuristic.
787
+ writePidSessionEntry({
788
+ pid: child.pid ?? 0,
789
+ agent: options.agent,
790
+ sessionId: options.sessionId,
791
+ cwd: options.cwd || process.cwd(),
792
+ tmuxPane: process.env.TMUX_PANE,
793
+ startedAtMs: Date.now(),
794
+ });
773
795
  // Mark startup time (time from function call to process spawn)
774
796
  timer.mark('startup');
775
797
  let budgetKilled = false;
package/dist/lib/hooks.js CHANGED
@@ -910,6 +910,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
910
910
  if (agentId === 'claude') {
911
911
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
912
912
  }
913
+ if (agentId === 'droid') {
914
+ // Droid's settings.json hooks schema is identical to Claude's (top-level
915
+ // `hooks` object → event → matcher-group array), so reuse the Claude
916
+ // registrar targeting `.factory/settings.json` (agentConfigDirName('droid')).
917
+ return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('droid'));
918
+ }
913
919
  if (agentId === 'codex') {
914
920
  return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
915
921
  }
@@ -953,10 +959,10 @@ const ANTIGRAVITY_EVENT_MAP = {
953
959
  const GEMINI_EVENT_MAP = {
954
960
  UserPromptSubmit: 'BeforeAgent',
955
961
  };
956
- function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes) {
962
+ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
957
963
  const registered = [];
958
964
  const errors = [];
959
- const configDir = path.join(versionHome, '.claude');
965
+ const configDir = path.join(versionHome, configDirName);
960
966
  const settingsPath = path.join(configDir, 'settings.json');
961
967
  let config = {};
962
968
  if (fs.existsSync(settingsPath)) {
@@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
12
12
  import { sshExec, shellQuote } from '../ssh-exec.js';
13
13
  import { sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
- import { saveTask, updateTask } from './tasks.js';
15
+ import { saveTask, updateTask, terminalPatch } from './tasks.js';
16
16
  import { followHostTask } from './progress.js';
17
17
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
18
18
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
@@ -61,12 +61,11 @@ async function launchDetached(host, target, opts) {
61
61
  echo: true,
62
62
  timeoutMs: opts.timeoutMs,
63
63
  });
64
- const finished = updateTask(id, {
65
- status: exitCode === 0 ? 'completed' : exitCode === -1 ? 'unknown' : 'failed',
66
- exitCode: exitCode === -1 ? undefined : exitCode,
67
- finishedAt: new Date().toISOString(),
68
- });
69
- return { task: finished ?? task, exitCode };
64
+ // -1 = the follow window closed while the run continues on the host. Leave the
65
+ // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
66
+ // reconcile against the remote `.exit` resolves the true final status.
67
+ const finished = exitCode === -1 ? task : (updateTask(id, terminalPatch(exitCode)) ?? task);
68
+ return { task: finished, exitCode };
70
69
  }
71
70
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
72
71
  export async function dispatchToHost(host, opts) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared host-task log viewer — the show-or-follow core behind both
3
+ * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
+ *
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`);
6
+ * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
+ * place so the two commands can never drift.
8
+ */
9
+ export interface HostLogResult {
10
+ /** False when no host task with this id exists (caller may fall through to sessions). */
11
+ found: boolean;
12
+ /** Process exit code to adopt when the task was shown/followed. */
13
+ exitCode?: number;
14
+ }
15
+ /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
+ export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared host-task log viewer — the show-or-follow core behind both
3
+ * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
+ *
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`);
6
+ * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
+ * place so the two commands can never drift.
8
+ */
9
+ import * as fs from 'fs';
10
+ import chalk from 'chalk';
11
+ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
12
+ import { followHostTask } from './progress.js';
13
+ import { reconcileTask } from './reconcile.js';
14
+ /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
15
+ export async function showHostTaskLog(id, follow) {
16
+ const task = loadTask(id);
17
+ if (!task)
18
+ return { found: false };
19
+ if (follow && task.status === 'running') {
20
+ const code = await followHostTask(task.target, {
21
+ remoteLog: task.remoteLog,
22
+ remoteExit: task.remoteExit,
23
+ taskId: id,
24
+ echo: true,
25
+ });
26
+ // -1 = follow window closed; the run continues on the host (not a failure,
27
+ // so exit 0). Any real code is the finished run — persist the terminal
28
+ // status the killed dispatch follower would have written.
29
+ if (code === -1)
30
+ return { found: true, exitCode: 0 };
31
+ updateTask(id, terminalPatch(code));
32
+ return { found: true, exitCode: code };
33
+ }
34
+ // Non-follow view: heal a still-'running' record from the remote `.exit` so a
35
+ // plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
36
+ // ssh) once the record is already terminal.
37
+ reconcileTask(task);
38
+ try {
39
+ process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
40
+ }
41
+ catch {
42
+ console.log(chalk.gray('(no local log captured for this task)'));
43
+ }
44
+ return { found: true, exitCode: 0 };
45
+ }
@@ -5,6 +5,13 @@
5
5
  * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
6
  * a dropped connection resumes from the saved offset) and finish when `.exit`
7
7
  * appears. Rich transcript-parser rendering is a fast-follow.
8
+ *
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.
8
15
  */
9
16
  export interface FollowOptions {
10
17
  remoteLog: string;
@@ -15,7 +22,66 @@ export interface FollowOptions {
15
22
  echo?: boolean;
16
23
  /** Overall wall-clock cap; returns -1 on timeout. */
17
24
  timeoutMs?: number;
25
+ /** Fast poll interval while output is flowing (default 1500ms). */
18
26
  pollMs?: number;
27
+ /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
28
+ maxPollMs?: number;
19
29
  }
30
+ /**
31
+ * Build the per-task sentinel that separates the log tail from the exit-file
32
+ * contents in one combined fetch. The task id (8 hex chars) makes collision with
33
+ * the agent's own output effectively impossible; callers still split on the LAST
34
+ * occurrence so a token echoed into the log can never be mistaken for the real
35
+ * trailing marker.
36
+ */
37
+ export declare function exitMarker(taskId: string): string;
38
+ /**
39
+ * Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
40
+ * BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
41
+ * output happened to echo the token, the real trailing sentinel still wins.
42
+ * Returns null when the marker is absent (a transient fetch miss — the remote
43
+ * shell never ran our printf), telling the caller to retry without advancing.
44
+ *
45
+ * Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
46
+ * of log bytes consumed on the wire, which the follow loop adds to its offset. A
47
+ * string split would first UTF-8-decode, turning any multibyte char split at the
48
+ * `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
49
+ * drifting the offset (see followHostTask). `consumed` is returned explicitly for
50
+ * clarity; it always equals `logChunk.length`.
51
+ */
52
+ export declare function splitProgressBytes(buf: Buffer, taskId: string): {
53
+ logChunk: Buffer;
54
+ exit: Buffer;
55
+ consumed: number;
56
+ } | null;
57
+ /**
58
+ * One round-trip: new log bytes since `offset`, the sentinel, then the exit
59
+ * file. Returns null on a transient fetch miss (ssh error / marker absent) so
60
+ * the caller simply retries next cycle without advancing the offset.
61
+ *
62
+ * `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
63
+ * intentionally unquoted so the remote shell expands $HOME.
64
+ */
65
+ export declare function fetchProgress(target: string, opts: {
66
+ remoteLog: string;
67
+ remoteExit: string;
68
+ taskId: string;
69
+ offset: number;
70
+ }): {
71
+ logChunk: Buffer;
72
+ exit: string;
73
+ } | null;
74
+ /**
75
+ * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
76
+ * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
77
+ */
78
+ export declare function readRemoteFileId(target: string, remotePath: string): string | null;
79
+ /**
80
+ * True when the local mirror file IS the very file we're tailing — the
81
+ * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
82
+ * resolve to the same inode. Appending our read bytes back into it would feed the
83
+ * tail and multiply the log, so the caller must skip the mirror write.
84
+ */
85
+ export declare function mirrorAliasesSource(localId: string | null, remoteId: string | null): boolean;
20
86
  /** Tail the remote log to stdout until the run finishes; return its exit code. */
21
87
  export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;