@phnx-labs/agents-cli 1.20.33 → 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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import chalk from 'chalk';
19
+ import { select, confirm } from '@inquirer/prompts';
20
+ import { AGENTS } from './agents.js';
21
+ import { pullRepo } from './git.js';
22
+ import { heal } from './heal.js';
23
+ import { promptAgentVersionSelection } from './versions.js';
24
+ import { isInteractiveTerminal, isPromptCancelled } from '../commands/utils.js';
25
+ import { computeSyncStatus, } from './sync-status.js';
26
+ const agentName = (id) => AGENTS[id]?.name ?? id;
27
+ /** "claude@2.1.170 2 drifted · 1 missing" for one version. */
28
+ function versionLine(v) {
29
+ const bits = [];
30
+ if (v.counts.drifted)
31
+ bits.push(`${v.counts.drifted} drifted`);
32
+ if (v.counts.missing)
33
+ bits.push(`${v.counts.missing} missing`);
34
+ const label = `${agentName(v.agent)}@${v.version}`;
35
+ return ` ${label.padEnd(28)} ${chalk.yellow(bits.join(' · '))}`;
36
+ }
37
+ /** Render the drift summary (system freshness + each version owed a sync). */
38
+ function renderSummary(status, needing) {
39
+ console.log(chalk.bold('\nSync status'));
40
+ if (status.system.behind > 0) {
41
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
42
+ }
43
+ for (const v of needing)
44
+ console.log(versionLine(v));
45
+ if (status.totals.orphan > 0) {
46
+ console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
47
+ }
48
+ }
49
+ /** Fast-forward the `.system` repo. Returns whether it actually moved. */
50
+ async function pullSystem(status) {
51
+ if (status.system.behind <= 0)
52
+ return false;
53
+ const res = await pullRepo(status.system.dir);
54
+ if (res.success) {
55
+ console.log(chalk.green(`Pulled .system (+${status.system.behind}).`));
56
+ return true;
57
+ }
58
+ console.log(chalk.red(`Could not pull .system: ${res.error ?? 'unknown error'}`));
59
+ return false;
60
+ }
61
+ /** Reconcile a set of versions grouped by agent via the shared heal engine. */
62
+ async function healVersions(versionsByAgent, cwd) {
63
+ const out = [];
64
+ for (const [agent, versions] of versionsByAgent) {
65
+ if (versions.length === 0)
66
+ continue;
67
+ const res = await heal({ mode: 'full', cwd, agent, versions });
68
+ out.push(...res.versions);
69
+ }
70
+ return out;
71
+ }
72
+ /** Report which agents received what after a heal. */
73
+ function reportHealed(healed) {
74
+ const touched = healed.filter((v) => v.healed.length > 0);
75
+ if (touched.length === 0) {
76
+ console.log(chalk.gray('Nothing to reconcile — homes already matched sources.'));
77
+ return;
78
+ }
79
+ const total = touched.reduce((n, v) => n + v.healed.length, 0);
80
+ const agents = [...new Set(touched.map((v) => agentName(v.agent)))].join(', ');
81
+ console.log(chalk.green(`Synced ${total} resource${total === 1 ? '' : 's'} to ${agents}.`));
82
+ }
83
+ function groupNeeding(needing) {
84
+ const m = new Map();
85
+ for (const v of needing) {
86
+ const list = m.get(v.agent) ?? [];
87
+ list.push(v.version);
88
+ m.set(v.agent, list);
89
+ }
90
+ return m;
91
+ }
92
+ /**
93
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
94
+ * callers (menu-bar, doctor) can report without re-scanning.
95
+ */
96
+ export async function promptDriftSync(opts = {}) {
97
+ const cwd = opts.cwd ?? process.cwd();
98
+ const status = opts.status ?? (await computeSyncStatus({ cwd }));
99
+ const needing = status.agents.filter((a) => a.needsSync);
100
+ const systemBehind = status.system.behind;
101
+ const base = {
102
+ systemBehindBefore: systemBehind,
103
+ systemPulled: false,
104
+ healed: [],
105
+ cancelled: false,
106
+ nothingToDo: false,
107
+ };
108
+ if (systemBehind <= 0 && needing.length === 0) {
109
+ console.log(chalk.green('Everything is in sync.'));
110
+ return { ...base, nothingToDo: true };
111
+ }
112
+ if (!opts.quiet)
113
+ renderSummary(status, needing);
114
+ // Non-interactive OR explicit --yes: reconcile everything detected.
115
+ if (opts.yes || !isInteractiveTerminal()) {
116
+ if (!opts.yes) {
117
+ // Non-TTY without --yes: report, don't act, don't throw.
118
+ console.log(chalk.gray('\nRun `agents status --yes` to sync, or `agents status` in a terminal to choose.'));
119
+ return base;
120
+ }
121
+ const systemPulled = await pullSystem(status);
122
+ const healed = await healVersions(groupNeeding(needing), cwd);
123
+ reportHealed(healed);
124
+ return { ...base, systemPulled, healed };
125
+ }
126
+ // Interactive gate.
127
+ let choice;
128
+ try {
129
+ choice = await select({
130
+ message: 'Sync now?',
131
+ choices: [
132
+ { name: 'Sync all detected', value: 'all' },
133
+ { name: 'Choose agents & resources', value: 'choose' },
134
+ { name: 'No', value: 'no' },
135
+ ],
136
+ default: 'all',
137
+ });
138
+ }
139
+ catch (err) {
140
+ if (isPromptCancelled(err))
141
+ return { ...base, cancelled: true };
142
+ throw err;
143
+ }
144
+ if (choice === 'no')
145
+ return { ...base, cancelled: true };
146
+ if (choice === 'all') {
147
+ const systemPulled = await pullSystem(status);
148
+ const healed = await healVersions(groupNeeding(needing), cwd);
149
+ reportHealed(healed);
150
+ return { ...base, systemPulled, healed };
151
+ }
152
+ // choice === 'choose': optional .system pull, then per-agent/version selection.
153
+ let systemPulled = false;
154
+ if (systemBehind > 0) {
155
+ try {
156
+ const pull = await confirm({ message: `Pull .system (${systemBehind} behind) first?`, default: true });
157
+ if (pull)
158
+ systemPulled = await pullSystem(status);
159
+ }
160
+ catch (err) {
161
+ if (!isPromptCancelled(err))
162
+ throw err;
163
+ return { ...base, systemPulled, cancelled: true };
164
+ }
165
+ }
166
+ const needingAgents = [...new Set(needing.map((a) => a.agent))];
167
+ let selection;
168
+ try {
169
+ selection = await promptAgentVersionSelection(needingAgents);
170
+ }
171
+ catch (err) {
172
+ if (isPromptCancelled(err))
173
+ return { ...base, systemPulled, cancelled: true };
174
+ throw err;
175
+ }
176
+ const healed = await healVersions(selection.versionSelections, cwd);
177
+ reportHealed(healed);
178
+ return { ...base, systemPulled, healed };
179
+ }
@@ -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) {
@@ -189,6 +189,21 @@ export declare function nativeResume(agent: AgentId): boolean;
189
189
  export declare function buildExecCommand(options: ExecOptions): string[];
190
190
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
191
191
  export declare function execAgent(options: ExecOptions): Promise<number>;
192
+ /**
193
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
194
+ *
195
+ * POSIX always execs the binary directly (no shell). On Windows a bare
196
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
197
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
198
+ * or extensionless path is exec'd through the shell / directly. npm always ships
199
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
200
+ * hands us is the `.cmd` (never a bare `.ps1`).
201
+ */
202
+ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: string, extraArgs: string[]): {
203
+ command: string;
204
+ args: string[];
205
+ shell: boolean;
206
+ };
192
207
  /**
193
208
  * Transparent passthrough exec for generated shims — the node-side delegate that
194
209
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
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
  *
@@ -374,15 +375,6 @@ export const AGENT_COMMANDS = {
374
375
  edit: [],
375
376
  },
376
377
  },
377
- roo: {
378
- base: ['roo'],
379
- promptFlag: 'positional',
380
- modeFlags: {
381
- plan: ['--mode', 'architect'],
382
- edit: ['--mode', 'code'],
383
- },
384
- modelFlag: '--model',
385
- },
386
378
  // TODO: --output-format json is documented but currently broken upstream
387
379
  // ("flags provided but not defined: -output-format"). Track resolution at
388
380
  // https://github.com/google-antigravity/antigravity-cli/issues/7 before
@@ -665,6 +657,25 @@ export async function execAgent(options) {
665
657
  const { exitCode } = await spawnAgent(options);
666
658
  return exitCode;
667
659
  }
660
+ /**
661
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
662
+ *
663
+ * POSIX always execs the binary directly (no shell). On Windows a bare
664
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
665
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
666
+ * or extensionless path is exec'd through the shell / directly. npm always ships
667
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
668
+ * hands us is the `.cmd` (never a bare `.ps1`).
669
+ */
670
+ export function resolveShimSpawn(platform, binary, extraArgs) {
671
+ if (platform === 'win32') {
672
+ // Use win32 path semantics regardless of the host running this (the platform
673
+ // is the parameter, not process.platform) so `C:\...` reads as absolute.
674
+ const useShell = !path.win32.isAbsolute(binary) || binary.endsWith('.cmd');
675
+ return { command: binary, args: extraArgs, shell: useShell };
676
+ }
677
+ return { command: binary, args: extraArgs, shell: false };
678
+ }
668
679
  /**
669
680
  * Transparent passthrough exec for generated shims — the node-side delegate that
670
681
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
@@ -691,9 +702,9 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
691
702
  // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
692
703
  // derives the per-version config-dir env); pass the agent's default to satisfy the type.
693
704
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
694
- const useShell = process.platform === 'win32' && (!path.isAbsolute(binary) || binary.endsWith('.cmd'));
705
+ const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
695
706
  return new Promise((resolve) => {
696
- const child = spawn(binary, [...launchArgs, ...rawArgs], { cwd, stdio: 'inherit', env, shell: useShell });
707
+ const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
697
708
  child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
698
709
  child.on('error', (err) => {
699
710
  process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
@@ -712,6 +723,15 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
712
723
  * unbuffered.
713
724
  */
714
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
+ }
715
735
  const cmd = buildExecCommand(options);
716
736
  const [executable, ...args] = cmd;
717
737
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
@@ -760,6 +780,18 @@ async function spawnAgent(options) {
760
780
  env: buildExecEnv(options),
761
781
  shell: useShell,
762
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
+ });
763
795
  // Mark startup time (time from function call to process spawn)
764
796
  timer.mark('startup');
765
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
+ }