@phnx-labs/agents-cli 1.20.78 → 1.20.82

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 (176) hide show
  1. package/CHANGELOG.md +487 -0
  2. package/README.md +25 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +65 -0
  12. package/dist/commands/doctor.js +575 -31
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +24 -5
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +187 -11
  29. package/dist/commands/routines.js +12 -2
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +100 -32
  33. package/dist/commands/sessions-inject.d.ts +7 -4
  34. package/dist/commands/sessions-inject.js +18 -18
  35. package/dist/commands/sessions-picker.js +32 -3
  36. package/dist/commands/sessions-resume.d.ts +16 -0
  37. package/dist/commands/sessions-resume.js +14 -13
  38. package/dist/commands/sessions.d.ts +31 -0
  39. package/dist/commands/sessions.js +141 -16
  40. package/dist/commands/set.d.ts +15 -0
  41. package/dist/commands/set.js +79 -0
  42. package/dist/commands/setup-fleet.d.ts +21 -0
  43. package/dist/commands/setup-fleet.js +201 -0
  44. package/dist/commands/setup-secrets.d.ts +20 -0
  45. package/dist/commands/setup-secrets.js +223 -0
  46. package/dist/commands/setup.js +17 -3
  47. package/dist/commands/skills.js +1 -1
  48. package/dist/commands/sync.js +7 -2
  49. package/dist/commands/teams-picker.js +0 -1
  50. package/dist/commands/teams.d.ts +33 -1
  51. package/dist/commands/teams.js +208 -44
  52. package/dist/commands/versions.js +10 -5
  53. package/dist/commands/view.d.ts +1 -0
  54. package/dist/commands/view.js +11 -0
  55. package/dist/index.js +5 -8
  56. package/dist/lib/acp/harnesses.js +0 -7
  57. package/dist/lib/activity.d.ts +156 -0
  58. package/dist/lib/activity.js +282 -0
  59. package/dist/lib/agents.d.ts +9 -2
  60. package/dist/lib/agents.js +72 -63
  61. package/dist/lib/browser/service.js +3 -0
  62. package/dist/lib/browser/types.d.ts +7 -0
  63. package/dist/lib/capabilities.js +6 -2
  64. package/dist/lib/crabbox/lease.js +2 -2
  65. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  66. package/dist/lib/crabbox/setup-copy.js +4 -4
  67. package/dist/lib/daemon.d.ts +18 -0
  68. package/dist/lib/daemon.js +47 -6
  69. package/dist/lib/devices/fleet.d.ts +1 -1
  70. package/dist/lib/devices/fleet.js +1 -1
  71. package/dist/lib/doctor-diff.d.ts +20 -0
  72. package/dist/lib/doctor-diff.js +6 -1
  73. package/dist/lib/drift.d.ts +31 -11
  74. package/dist/lib/drift.js +58 -7
  75. package/dist/lib/events.d.ts +8 -1
  76. package/dist/lib/events.js +10 -1
  77. package/dist/lib/exec.js +35 -8
  78. package/dist/lib/feed-post.d.ts +28 -1
  79. package/dist/lib/feed-post.js +110 -2
  80. package/dist/lib/fleet/apply.js +8 -1
  81. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  82. package/dist/lib/fleet/auth-sync.js +25 -11
  83. package/dist/lib/fleet/remote-login.js +5 -0
  84. package/dist/lib/git.d.ts +14 -14
  85. package/dist/lib/git.js +41 -44
  86. package/dist/lib/hooks.d.ts +42 -0
  87. package/dist/lib/hooks.js +137 -93
  88. package/dist/lib/hosts/dispatch.d.ts +28 -1
  89. package/dist/lib/hosts/dispatch.js +33 -5
  90. package/dist/lib/hosts/option.js +2 -2
  91. package/dist/lib/hosts/passthrough.d.ts +22 -1
  92. package/dist/lib/hosts/passthrough.js +238 -13
  93. package/dist/lib/hosts/run-target.d.ts +2 -0
  94. package/dist/lib/hosts/run-target.js +1 -0
  95. package/dist/lib/hq/floor.d.ts +1 -1
  96. package/dist/lib/hq/floor.js +6 -1
  97. package/dist/lib/mcp.js +3 -71
  98. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  99. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  100. package/dist/lib/menubar/notify-desktop.js +41 -5
  101. package/dist/lib/merged-resources.d.ts +11 -0
  102. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  103. package/dist/lib/permissions.d.ts +0 -32
  104. package/dist/lib/permissions.js +4 -157
  105. package/dist/lib/picker.js +1 -1
  106. package/dist/lib/platform/process.d.ts +16 -5
  107. package/dist/lib/platform/process.js +54 -0
  108. package/dist/lib/plugins.d.ts +0 -8
  109. package/dist/lib/plugins.js +4 -110
  110. package/dist/lib/project-resources.js +4 -1
  111. package/dist/lib/refresh.d.ts +5 -3
  112. package/dist/lib/refresh.js +7 -5
  113. package/dist/lib/resources/commands.js +4 -1
  114. package/dist/lib/resources/mcp.js +0 -4
  115. package/dist/lib/resources/permissions.d.ts +1 -1
  116. package/dist/lib/resources/permissions.js +1 -5
  117. package/dist/lib/resources/rules.js +4 -1
  118. package/dist/lib/resources/skills.js +4 -1
  119. package/dist/lib/resources/subagents.js +4 -0
  120. package/dist/lib/resources/types.d.ts +1 -1
  121. package/dist/lib/routines.d.ts +20 -0
  122. package/dist/lib/routines.js +6 -0
  123. package/dist/lib/run-defaults.d.ts +1 -0
  124. package/dist/lib/run-defaults.js +9 -0
  125. package/dist/lib/runner.d.ts +0 -12
  126. package/dist/lib/runner.js +49 -10
  127. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  129. package/dist/lib/secrets/agent.d.ts +16 -0
  130. package/dist/lib/secrets/agent.js +105 -3
  131. package/dist/lib/secrets/audit.d.ts +46 -0
  132. package/dist/lib/secrets/audit.js +56 -0
  133. package/dist/lib/secrets/bundles.d.ts +11 -8
  134. package/dist/lib/secrets/bundles.js +31 -23
  135. package/dist/lib/secrets/index.d.ts +2 -1
  136. package/dist/lib/secrets/index.js +8 -2
  137. package/dist/lib/secrets/remote.d.ts +75 -2
  138. package/dist/lib/secrets/remote.js +139 -5
  139. package/dist/lib/secrets/sync.js +3 -3
  140. package/dist/lib/session/active.d.ts +110 -26
  141. package/dist/lib/session/active.js +161 -50
  142. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  143. package/dist/lib/session/actor-sidecar.js +101 -0
  144. package/dist/lib/session/db.d.ts +4 -1
  145. package/dist/lib/session/db.js +65 -8
  146. package/dist/lib/session/discover.d.ts +4 -0
  147. package/dist/lib/session/discover.js +13 -0
  148. package/dist/lib/session/hook-sessions.d.ts +9 -0
  149. package/dist/lib/session/hook-sessions.js +57 -8
  150. package/dist/lib/session/pid-registry.d.ts +13 -0
  151. package/dist/lib/session/render.d.ts +2 -0
  152. package/dist/lib/session/render.js +1 -1
  153. package/dist/lib/session/types.d.ts +16 -0
  154. package/dist/lib/staleness/detectors/commands.js +2 -2
  155. package/dist/lib/staleness/detectors/permissions.js +1 -45
  156. package/dist/lib/staleness/writers/commands.js +4 -5
  157. package/dist/lib/staleness/writers/hooks.js +1 -1
  158. package/dist/lib/startup/command-registry.d.ts +1 -2
  159. package/dist/lib/startup/command-registry.js +2 -4
  160. package/dist/lib/subagents-registry.js +5 -12
  161. package/dist/lib/subagents.d.ts +0 -10
  162. package/dist/lib/subagents.js +0 -12
  163. package/dist/lib/teams/agents.d.ts +0 -2
  164. package/dist/lib/teams/agents.js +10 -27
  165. package/dist/lib/teams/index.d.ts +1 -1
  166. package/dist/lib/teams/index.js +1 -1
  167. package/dist/lib/types.d.ts +17 -9
  168. package/dist/lib/usage.d.ts +28 -5
  169. package/dist/lib/usage.js +271 -8
  170. package/dist/lib/versions.js +7 -1
  171. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  172. package/dist/lib/watchdog/watchdog.js +25 -13
  173. package/package.json +1 -2
  174. package/dist/commands/check.d.ts +0 -15
  175. package/dist/commands/check.js +0 -180
  176. package/dist/commands/resources.d.ts +0 -5
@@ -1,21 +1,27 @@
1
1
  /**
2
2
  * Shared drift-detection internals for `agents doctor` (overview mode) and
3
- * `agents check` (the scriptable, CI-friendly gate).
3
+ * `agents doctor --check` (the scriptable, CI-friendly gate).
4
4
  *
5
5
  * This is the single source of truth for "is the install out of sync?": the
6
6
  * per-version sync status (fresh / stale / never-synced) and the orphan census.
7
- * `agents doctor` renders it as a human report; `agents check` reduces it to an
8
- * exit code. Neither reimplements the diagnostic — both call `computeDrift`.
7
+ * `agents doctor` renders it as a human report; `agents doctor --check` reduces it
8
+ * to an exit code. Neither reimplements the diagnostic — both call `computeDrift`.
9
9
  */
10
10
  import type { AgentId } from './types.js';
11
- import { type VersionResourceReport } from './doctor-diff.js';
11
+ import { type VersionResourceReport, type SourceLayerBehind } from './doctor-diff.js';
12
12
  export interface SyncStatusRow {
13
13
  agent: AgentId;
14
14
  version: string;
15
15
  status: 'fresh' | 'stale' | 'never-synced';
16
16
  isDefault: boolean;
17
- /** For stale rows: prioritized lines naming exactly what diverged (plugins first). */
17
+ /** For stale rows: prioritized lines naming exactly what diverged (plugins first).
18
+ * Also carries hook-wiring divergence for a version whose hooks are present but
19
+ * not wired into settings.json — independent of manifest staleness. */
18
20
  divergence?: string[];
21
+ /** Count of hooks present on disk but NOT wired into the version's settings.json
22
+ * (claude/droid). A non-zero value makes the version out-of-sync even when the
23
+ * manifest reads fresh — the yosemite-s1 blind spot the CI gate must catch. */
24
+ unwiredHooks?: number;
19
25
  }
20
26
  export interface OrphanRow {
21
27
  agent: AgentId;
@@ -27,6 +33,14 @@ export interface OrphanRow {
27
33
  export declare function divergenceLines(report: VersionResourceReport): string[];
28
34
  export declare function checkSyncStatus(cwd: string): SyncStatusRow[];
29
35
  export declare function countOrphans(): OrphanRow[];
36
+ /**
37
+ * Probe each source layer (user, system, enabled extras) for how far it trails
38
+ * its upstream, from the LAST-FETCHED remote-tracking ref (no network). A layer
39
+ * behind origin means every version home is reconciled against stale truth — a
40
+ * drift signal `agents doctor --check` must fail on, not a buried preamble. Returns
41
+ * only the behind layers. Canonical home for both `agents doctor` and `agents doctor --check`.
42
+ */
43
+ export declare function computeSourceBehind(): SourceLayerBehind[];
30
44
  export interface DriftSummary {
31
45
  syncRows: SyncStatusRow[];
32
46
  orphanRows: OrphanRow[];
@@ -36,17 +50,23 @@ export interface DriftSummary {
36
50
  neverSyncedCount: number;
37
51
  /** Versions carrying orphan resources (informational — not a drift signal). */
38
52
  orphanVersionCount: number;
53
+ /** Versions with hooks present on disk but not wired into settings.json. */
54
+ unwiredHookVersions: number;
55
+ /** Source layers behind their upstream (reconciled against stale truth). */
56
+ sourceBehind: SourceLayerBehind[];
39
57
  /**
40
- * True when any installed version is stale or never-synced the exact signal
41
- * `agents doctor` surfaces as "run `agents status` to review what has drifted".
42
- * Orphans are a `prune` concern, not sync drift, so they do NOT set this flag
43
- * (mirrors the sync-status engine: an orphan alone never flags needsSync).
58
+ * True when the install is out of sync: any installed version is stale,
59
+ * never-synced, or carries unwired hooks, OR a source layer is behind origin.
60
+ * `agents doctor` surfaces it as "run `agents status`"; `agents doctor --check`
61
+ * maps it to a non-zero exit. Orphans are a `prune` concern, not sync drift, so they do
62
+ * NOT set this flag (mirrors the sync-status engine: an orphan alone never
63
+ * flags needsSync).
44
64
  */
45
65
  hasDrift: boolean;
46
66
  }
47
67
  /**
48
68
  * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
49
- * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
50
- * to an exit code; the readout `agents doctor` renders in full.
69
+ * to a summary with a single `hasDrift` boolean. The gate `agents doctor --check`
70
+ * maps to an exit code; the readout `agents doctor` renders in full.
51
71
  */
52
72
  export declare function computeDrift(cwd: string): DriftSummary;
package/dist/lib/drift.js CHANGED
@@ -4,7 +4,9 @@ import { loadManifest, isStale } from './staleness/index.js';
4
4
  import { diffVersionResources } from './doctor-diff.js';
5
5
  import { diffVersionCommands, iterCommandsCapableVersions } from './commands.js';
6
6
  import { diffVersionSkills, iterSkillsCapableVersions } from './skills.js';
7
- import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from './hooks.js';
7
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome, checkVersionHookWiring } from './hooks.js';
8
+ import { commitsBehindUpstream } from './git.js';
9
+ import { getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos } from './state.js';
8
10
  // Lines naming exactly what's out of sync for a version, plugins prioritized:
9
11
  // each divergent plugin gets its own line with specifics (stale mirror version,
10
12
  // invalid manifest, or the bundled skills/commands missing from the mirror —
@@ -46,14 +48,37 @@ export function checkSyncStatus(cwd) {
46
48
  ? 'never-synced'
47
49
  : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
48
50
  const row = { agent, version, status, isDefault: version === def };
51
+ const divergence = [];
49
52
  if (status === 'stale') {
50
53
  // Resolve the specifics against non-project layers (the global home is
51
54
  // never reconciled against per-cwd project resources).
52
55
  const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
53
- const lines = divergenceLines(report);
54
- if (lines.length)
55
- row.divergence = lines;
56
+ divergence.push(...divergenceLines(report));
56
57
  }
58
+ // Hook WIRING is independent of manifest staleness: a hook file can be
59
+ // byte-identical to source (fresh) yet absent from settings.json, so it
60
+ // never fires. Surface that for every version, fresh or stale, so overview
61
+ // AND `agents doctor --check` flag it (claude/droid; other agents report unsupported).
62
+ const wiring = checkVersionHookWiring(agent, version);
63
+ if (wiring.supported) {
64
+ const expected = wiring.expected ?? 0;
65
+ if (wiring.settingsMissing && expected > 0) {
66
+ row.unwiredHooks = expected;
67
+ divergence.push(`hooks settings.json missing — ${expected} declared hook(s) never fire`);
68
+ }
69
+ else if (wiring.settingsUnparseable) {
70
+ row.unwiredHooks = Math.max(1, expected);
71
+ divergence.push(`hooks settings.json unparseable — wiring cannot be verified`);
72
+ }
73
+ else if (wiring.unwired.length > 0) {
74
+ row.unwiredHooks = wiring.unwired.length;
75
+ const names = wiring.unwired.map((u) => u.name);
76
+ const shown = names.slice(0, 3).join(', ');
77
+ divergence.push(`hooks ${wiring.unwired.length} unwired (${shown}${names.length > 3 ? ', …' : ''})`);
78
+ }
79
+ }
80
+ if (divergence.length)
81
+ row.divergence = divergence;
57
82
  rows.push(row);
58
83
  }
59
84
  }
@@ -91,22 +116,48 @@ export function countOrphans() {
91
116
  }
92
117
  return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
93
118
  }
119
+ /**
120
+ * Probe each source layer (user, system, enabled extras) for how far it trails
121
+ * its upstream, from the LAST-FETCHED remote-tracking ref (no network). A layer
122
+ * behind origin means every version home is reconciled against stale truth — a
123
+ * drift signal `agents doctor --check` must fail on, not a buried preamble. Returns
124
+ * only the behind layers. Canonical home for both `agents doctor` and `agents doctor --check`.
125
+ */
126
+ export function computeSourceBehind() {
127
+ const out = [];
128
+ const probe = (layer, dir, label, alias) => {
129
+ const r = commitsBehindUpstream(dir);
130
+ if (r && r.behind > 0)
131
+ out.push({ layer, label, alias, behind: r.behind, branch: r.branch });
132
+ };
133
+ probe('user', getUserAgentsDir(), '~/.agents', 'user');
134
+ probe('system', getSystemAgentsDir(), '~/.agents/.system', 'system');
135
+ for (const e of getEnabledExtraRepos())
136
+ probe('extra', e.dir, e.alias, e.alias);
137
+ return out;
138
+ }
94
139
  /**
95
140
  * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
96
- * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
97
- * to an exit code; the readout `agents doctor` renders in full.
141
+ * to a summary with a single `hasDrift` boolean. The gate `agents doctor --check`
142
+ * maps to an exit code; the readout `agents doctor` renders in full.
98
143
  */
99
144
  export function computeDrift(cwd) {
100
145
  const syncRows = checkSyncStatus(cwd);
101
146
  const orphanRows = countOrphans();
102
147
  const staleCount = syncRows.filter((r) => r.status === 'stale').length;
103
148
  const neverSyncedCount = syncRows.filter((r) => r.status === 'never-synced').length;
149
+ const unwiredHookVersions = syncRows.filter((r) => (r.unwiredHooks ?? 0) > 0).length;
150
+ const sourceBehind = computeSourceBehind();
104
151
  return {
105
152
  syncRows,
106
153
  orphanRows,
107
154
  staleCount,
108
155
  neverSyncedCount,
109
156
  orphanVersionCount: orphanRows.length,
110
- hasDrift: syncRows.some((r) => r.status !== 'fresh'),
157
+ unwiredHookVersions,
158
+ sourceBehind,
159
+ hasDrift: syncRows.some((r) => r.status !== 'fresh') ||
160
+ unwiredHookVersions > 0 ||
161
+ sourceBehind.length > 0,
111
162
  };
112
163
  }
@@ -11,8 +11,9 @@
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
+ import { type ActorKind } from './actor.js';
14
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
15
- 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' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
+ 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.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
17
  export declare function levelFor(event: EventType): EventLevel;
17
18
  export interface EventMeta {
18
19
  ts: string;
@@ -30,6 +31,10 @@ export interface EventMeta {
30
31
  osUser: string;
31
32
  transport: 'local' | 'ssh';
32
33
  sshClientIp?: string;
34
+ /** Resolved actor id — which human/agent is behind this event (RUSH-2020). */
35
+ actor?: string;
36
+ /** Actor kind (`human`/`agent`). */
37
+ kind?: ActorKind;
33
38
  }
34
39
  export interface EventPayload {
35
40
  agent?: string;
@@ -196,6 +201,8 @@ export interface EventStats {
196
201
  byEvent: Record<string, number>;
197
202
  byModule: Record<string, number>;
198
203
  byUser: Record<string, number>;
204
+ /** Event counts grouped by resolved actor id (the human/agent behind them). */
205
+ byActor: Record<string, number>;
199
206
  fileCount: number;
200
207
  totalBytes: number;
201
208
  }
@@ -19,6 +19,7 @@ import { gzipSync, gunzipSync } from 'node:zlib';
19
19
  import { parseSshConnection } from './session/provenance.js';
20
20
  import { ensureLockTarget, withFileLock } from './fs-atomic.js';
21
21
  import { getUserAgentsDir } from './state.js';
22
+ import { resolveActor } from './actor.js';
22
23
  // ─── Constants ────────────────────────────────────────────────────────────────
23
24
  // Resolved lazily: events.ts is imported transitively by most CLI surfaces, and
24
25
  // import itself must stay side-effect free. Tests may override the exact path.
@@ -51,7 +52,7 @@ const DIR_MODE = 0o700;
51
52
  const FILE_MODE = 0o600;
52
53
  const AUDIT_EVENTS = new Set([
53
54
  'command.start', 'command.end',
54
- 'secrets.get', 'secrets.set', 'secrets.delete', 'secrets.rename',
55
+ 'secrets.get', 'secrets.unlocked', 'secrets.set', 'secrets.delete', 'secrets.rename',
55
56
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
56
57
  'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
57
58
  'version.install', 'version.switch', 'version.remove',
@@ -119,6 +120,7 @@ const SENSITIVE_PAYLOAD_KEY = /password|secret|token|api[-_]?key|auth/i;
119
120
  const RESERVED_META_KEYS = new Set([
120
121
  'ts', 'tz', 'tzName', 'hostname', 'platform', 'arch', 'pid', 'ppid',
121
122
  'event', 'level', 'caller', 'session', 'osUser', 'transport', 'sshClientIp',
123
+ 'actor', 'kind',
122
124
  ]);
123
125
  function promptMarker(value) {
124
126
  const { prompt_length, prompt_sha256 } = redactPrompt(value);
@@ -276,10 +278,13 @@ function auditOrigin() {
276
278
  // Container/edge cases where the uid has no passwd entry.
277
279
  }
278
280
  const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
281
+ const actor = resolveActor();
279
282
  _origin = {
280
283
  osUser,
281
284
  transport: ssh ? 'ssh' : 'local',
282
285
  ...(ssh ? { sshClientIp: ssh.clientIp } : {}),
286
+ actor: actor.id,
287
+ kind: actor.kind,
283
288
  };
284
289
  return _origin;
285
290
  }
@@ -693,6 +698,7 @@ export function stats(options = {}) {
693
698
  const byEvent = {};
694
699
  const byModule = {};
695
700
  const byUser = {};
701
+ const byActor = {};
696
702
  for (const r of records) {
697
703
  const lvl = r.level ?? levelFor(r.event);
698
704
  byLevel[lvl] = (byLevel[lvl] ?? 0) + 1;
@@ -701,6 +707,8 @@ export function stats(options = {}) {
701
707
  byModule[r.module] = (byModule[r.module] ?? 0) + 1;
702
708
  const user = `${r.osUser ?? '?'}@${r.hostname}`;
703
709
  byUser[user] = (byUser[user] ?? 0) + 1;
710
+ if (r.actor)
711
+ byActor[r.actor] = (byActor[r.actor] ?? 0) + 1;
704
712
  }
705
713
  let fileCount = 0;
706
714
  let totalBytes = 0;
@@ -723,6 +731,7 @@ export function stats(options = {}) {
723
731
  byEvent,
724
732
  byModule,
725
733
  byUser,
734
+ byActor,
726
735
  fileCount,
727
736
  totalBytes,
728
737
  };
package/dist/lib/exec.js CHANGED
@@ -20,6 +20,7 @@ import { getShimsDir, getHistoryDir } from './state.js';
20
20
  import { resolveCodexHome } from './codex-home.js';
21
21
  import { readCodexConfiguredModel } from './shims.js';
22
22
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
23
+ import { writeSessionActorRecord } from './session/actor-sidecar.js';
23
24
  import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
24
25
  import { sessionIdMarkerLine } from './hosts/session-marker.js';
25
26
  import { recordRunName } from './session/run-names.js';
@@ -563,13 +564,6 @@ export const AGENT_COMMANDS = {
563
564
  },
564
565
  modelFlag: '--model',
565
566
  },
566
- forge: {
567
- base: ['forge'],
568
- promptFlag: 'positional',
569
- modeFlags: {
570
- edit: [],
571
- },
572
- },
573
567
  };
574
568
  /**
575
569
  * Whether `agent` has a native resume form (Tier 1). Derived solely from the
@@ -904,15 +898,28 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
904
898
  // wrapper, not the agent binary — the active scan resolves that by
905
899
  // walking the candidate's ancestors (readAncestorSessionEntry).
906
900
  if (child.pid) {
901
+ const passthroughSessionId = extractSessionIdArg(rawArgs);
907
902
  writePidSessionEntry({
908
903
  pid: child.pid,
909
904
  agent,
910
- sessionId: extractSessionIdArg(rawArgs),
905
+ sessionId: passthroughSessionId,
911
906
  cwd,
907
+ actor: resolveActor().id,
908
+ initiatedBy: resolveActor().kind,
912
909
  launchId,
913
910
  terminalId: process.env.AGENT_TERMINAL_ID,
914
911
  startedAtMs: Date.now(),
915
912
  });
913
+ // Durable sessionId -> actor record (RUSH-2019) so the scanner can attribute
914
+ // this session to a person after the pid dies. Best-effort; no-ops without id.
915
+ if (passthroughSessionId) {
916
+ writeSessionActorRecord({
917
+ sessionId: passthroughSessionId,
918
+ actor: resolveActor().id,
919
+ initiatedBy: resolveActor().kind,
920
+ startedAtMs: Date.now(),
921
+ });
922
+ }
916
923
  }
917
924
  child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
918
925
  child.on('error', (err) => {
@@ -1059,6 +1066,8 @@ async function runInTmux(options, executable, args) {
1059
1066
  agent: options.agent,
1060
1067
  sessionId: options.sessionId,
1061
1068
  cwd,
1069
+ actor: resolveActor().id,
1070
+ initiatedBy: resolveActor().kind,
1062
1071
  // spawnAgent injected AGENT_LAUNCH_ID into options.env before delegating
1063
1072
  // here; record the same id so the hook (running under the pane-leaf agent
1064
1073
  // pid) reconciles by launchId. This pane's pid usually IS the agent pid,
@@ -1068,6 +1077,14 @@ async function runInTmux(options, executable, args) {
1068
1077
  tmuxPane: pane,
1069
1078
  startedAtMs: Date.now(),
1070
1079
  });
1080
+ if (options.sessionId) {
1081
+ writeSessionActorRecord({
1082
+ sessionId: options.sessionId,
1083
+ actor: resolveActor().id,
1084
+ initiatedBy: resolveActor().kind,
1085
+ startedAtMs: Date.now(),
1086
+ });
1087
+ }
1071
1088
  }
1072
1089
  // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
1073
1090
  // detaches the client the instant the agent exits, so a fast failure (a
@@ -1279,11 +1296,21 @@ async function spawnAgent(options) {
1279
1296
  agent: options.agent,
1280
1297
  sessionId: options.sessionId,
1281
1298
  cwd: options.cwd || process.cwd(),
1299
+ actor: resolveActor().id,
1300
+ initiatedBy: resolveActor().kind,
1282
1301
  launchId,
1283
1302
  terminalId: process.env.AGENT_TERMINAL_ID,
1284
1303
  tmuxPane: process.env.TMUX_PANE,
1285
1304
  startedAtMs: Date.now(),
1286
1305
  });
1306
+ if (options.sessionId) {
1307
+ writeSessionActorRecord({
1308
+ sessionId: options.sessionId,
1309
+ actor: resolveActor().id,
1310
+ initiatedBy: resolveActor().kind,
1311
+ startedAtMs: Date.now(),
1312
+ });
1313
+ }
1287
1314
  // Mark startup time (time from function call to process spawn)
1288
1315
  timer.mark('startup');
1289
1316
  let budgetKilled = false;
@@ -1,4 +1,4 @@
1
- import { type ActivityEvent } from './activity.js';
1
+ import { type ActivityEvent, type Attachment } from './activity.js';
2
2
  import { type PidSessionEntry } from './session/pid-registry.js';
3
3
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
4
4
  export declare const STATUS_POST_MAX_CHARS = 500;
@@ -7,8 +7,16 @@ export interface FeedPostInput {
7
7
  text: string;
8
8
  /** Override session id (escape hatch for scripts/tests). Prefer auto-resolve. */
9
9
  sessionId?: string;
10
+ /** Generic artifacts to attach: local paths (copied for durability) or URLs. */
11
+ attach?: string[];
10
12
  /** Override activity root (tests). */
11
13
  activityRoot?: string;
14
+ /**
15
+ * Override the attachments store root (tests). Local files are copied under
16
+ * `<attachmentsRoot>/<sessionId>/<updateId>/`. Defaults to
17
+ * `~/.agents/.history/attachments`.
18
+ */
19
+ attachmentsRoot?: string;
12
20
  /** Override env for identity resolution (tests). */
13
21
  env?: NodeJS.ProcessEnv;
14
22
  /** Override cwd stamp (defaults to process.cwd() / env). */
@@ -55,6 +63,25 @@ export declare function resolvePostIdentity(input: Pick<FeedPostInput, 'sessionI
55
63
  export declare function walkPidRegistry(startPid: number, getParent: (pid: number) => number | undefined, readEntry: (pid: number) => PidSessionEntry | undefined): PidSessionEntry | undefined;
56
64
  /** Best-effort parent pid of `pid` (Linux /proc, else `ps`). */
57
65
  export declare function parentPidOf(pid: number): number | undefined;
66
+ /**
67
+ * Turn one `--attach` token into an {@link Attachment}. Remote URLs become
68
+ * `link` (or a media kind when the extension is unambiguous). Local files are
69
+ * classified by extension and, when `copyRoot` is set, copied under
70
+ * `<copyRoot>/<sessionId>/<updateId>/<basename>` so the link survives a worktree
71
+ * delete — the stored `href` then points at the durable copy. A missing local
72
+ * file is dropped (fail-open) rather than throwing.
73
+ */
74
+ export declare function buildAttachment(token: string, ctx: {
75
+ copyRoot?: string;
76
+ sessionId: string;
77
+ updateId: string;
78
+ }): Attachment | undefined;
79
+ /** Classify + store every `--attach` token; empty/failed tokens are dropped. */
80
+ export declare function buildAttachments(tokens: string[] | undefined, ctx: {
81
+ copyRoot?: string;
82
+ sessionId: string;
83
+ updateId: string;
84
+ }): Attachment[];
58
85
  export declare function normalizeStatusText(text: string): string;
59
86
  /**
60
87
  * Append a `status.posted` milestone for the calling agent.
@@ -15,7 +15,8 @@
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
17
  import { spawnSync } from 'child_process';
18
- import { appendActivityEvent, } from './activity.js';
18
+ import { appendActivityEvent, projectFromCwd, } from './activity.js';
19
+ import { getHistoryDir } from './state.js';
19
20
  import { machineId } from './machine-id.js';
20
21
  import { isValidMailboxId } from './mailbox.js';
21
22
  import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
@@ -141,6 +142,104 @@ export function parentPidOf(pid) {
141
142
  }
142
143
  return undefined;
143
144
  }
145
+ /** Extension → (kind, mediaType) for attachment classification. */
146
+ const MEDIA_BY_EXT = {
147
+ '.png': { kind: 'image', mediaType: 'image/png' },
148
+ '.jpg': { kind: 'image', mediaType: 'image/jpeg' },
149
+ '.jpeg': { kind: 'image', mediaType: 'image/jpeg' },
150
+ '.gif': { kind: 'image', mediaType: 'image/gif' },
151
+ '.svg': { kind: 'image', mediaType: 'image/svg+xml' },
152
+ '.webp': { kind: 'image', mediaType: 'image/webp' },
153
+ '.wav': { kind: 'audio', mediaType: 'audio/wav' },
154
+ '.mp3': { kind: 'audio', mediaType: 'audio/mpeg' },
155
+ '.m4a': { kind: 'audio', mediaType: 'audio/mp4' },
156
+ '.aac': { kind: 'audio', mediaType: 'audio/aac' },
157
+ '.ogg': { kind: 'audio', mediaType: 'audio/ogg' },
158
+ '.flac': { kind: 'audio', mediaType: 'audio/flac' },
159
+ '.mp4': { kind: 'video', mediaType: 'video/mp4' },
160
+ '.mov': { kind: 'video', mediaType: 'video/quicktime' },
161
+ '.webm': { kind: 'video', mediaType: 'video/webm' },
162
+ '.mkv': { kind: 'video', mediaType: 'video/x-matroska' },
163
+ };
164
+ /** True when the token is an http(s) URL (a remote attachment / link). */
165
+ function isRemoteUrl(token) {
166
+ return /^https?:\/\//i.test(token.trim());
167
+ }
168
+ function mediaForExt(ext) {
169
+ const hit = MEDIA_BY_EXT[ext.toLowerCase()];
170
+ return hit ? { kind: hit.kind, mediaType: hit.mediaType } : { kind: 'file' };
171
+ }
172
+ /** A short, filesystem-safe id grouping one post's copied attachments. */
173
+ function newUpdateId(ts) {
174
+ const rand = Math.random().toString(36).slice(2, 8);
175
+ const stamp = ts.replace(/[^0-9]/g, '').slice(0, 14) || 'post';
176
+ return `${stamp}-${rand}`;
177
+ }
178
+ /**
179
+ * Turn one `--attach` token into an {@link Attachment}. Remote URLs become
180
+ * `link` (or a media kind when the extension is unambiguous). Local files are
181
+ * classified by extension and, when `copyRoot` is set, copied under
182
+ * `<copyRoot>/<sessionId>/<updateId>/<basename>` so the link survives a worktree
183
+ * delete — the stored `href` then points at the durable copy. A missing local
184
+ * file is dropped (fail-open) rather than throwing.
185
+ */
186
+ export function buildAttachment(token, ctx) {
187
+ const value = token.trim();
188
+ if (!value)
189
+ return undefined;
190
+ if (isRemoteUrl(value)) {
191
+ const ext = path.extname(new URL(value).pathname);
192
+ const media = mediaForExt(ext);
193
+ const att = { kind: media.kind === 'file' ? 'link' : media.kind, href: value };
194
+ const name = path.basename(new URL(value).pathname);
195
+ if (name)
196
+ att.name = name;
197
+ if (media.mediaType)
198
+ att.mediaType = media.mediaType;
199
+ return att;
200
+ }
201
+ const abs = path.resolve(value);
202
+ let stat;
203
+ try {
204
+ stat = fs.statSync(abs);
205
+ }
206
+ catch {
207
+ return undefined; // dropped: local file does not exist
208
+ }
209
+ if (!stat.isFile())
210
+ return undefined;
211
+ const media = mediaForExt(path.extname(abs));
212
+ const name = path.basename(abs);
213
+ let href = abs;
214
+ if (ctx.copyRoot) {
215
+ try {
216
+ const destDir = path.join(ctx.copyRoot, ctx.sessionId, ctx.updateId);
217
+ fs.mkdirSync(destDir, { recursive: true });
218
+ const dest = path.join(destDir, name);
219
+ fs.copyFileSync(abs, dest);
220
+ href = dest;
221
+ }
222
+ catch {
223
+ href = abs; // copy failed: fall back to the original path
224
+ }
225
+ }
226
+ const att = { kind: media.kind, href, name, bytes: stat.size };
227
+ if (media.mediaType)
228
+ att.mediaType = media.mediaType;
229
+ return att;
230
+ }
231
+ /** Classify + store every `--attach` token; empty/failed tokens are dropped. */
232
+ export function buildAttachments(tokens, ctx) {
233
+ if (!tokens?.length)
234
+ return [];
235
+ const out = [];
236
+ for (const token of tokens) {
237
+ const att = buildAttachment(token, ctx);
238
+ if (att)
239
+ out.push(att);
240
+ }
241
+ return out;
242
+ }
144
243
  export function normalizeStatusText(text) {
145
244
  const collapsed = text.replace(/\s+/g, ' ').trim();
146
245
  if (!collapsed)
@@ -163,8 +262,15 @@ export function postFeedStatus(input) {
163
262
  throw new Error('No session id. Run from an agents-cli session '
164
263
  + '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
165
264
  }
265
+ const ts = input.ts ?? new Date().toISOString();
266
+ const project = projectFromCwd(identity.cwd);
267
+ const attachments = buildAttachments(input.attach, {
268
+ copyRoot: input.attachmentsRoot ?? path.join(getHistoryDir(), 'attachments'),
269
+ sessionId: identity.sessionId,
270
+ updateId: newUpdateId(ts),
271
+ });
166
272
  const event = {
167
- ts: input.ts ?? new Date().toISOString(),
273
+ ts,
168
274
  event: 'status.posted',
169
275
  sessionId: identity.sessionId,
170
276
  mailboxId: identity.mailboxId,
@@ -174,10 +280,12 @@ export function postFeedStatus(input) {
174
280
  agent: identity.agent,
175
281
  tool: 'feed.post',
176
282
  detail,
283
+ ...(project ? { project } : {}),
177
284
  ...(identity.pid !== undefined ? { pid: identity.pid } : {}),
178
285
  ...(identity.launchId ? { launchId: identity.launchId } : {}),
179
286
  ...(identity.terminalId ? { terminalId: identity.terminalId } : {}),
180
287
  ...(identity.tmuxPane ? { tmuxPane: identity.tmuxPane } : {}),
288
+ ...(attachments.length ? { attachments } : {}),
181
289
  };
182
290
  appendActivityEvent(event, input.activityRoot);
183
291
  return {
@@ -12,7 +12,7 @@ import { sshTargetFor } from '../devices/connect.js';
12
12
  import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
13
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
14
14
  import { sshExec } from '../ssh-exec.js';
15
- import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './auth-sync.js';
15
+ import { isPropagatableAgent, hasPortableAuthFiles, isCredentialSafeToPropagate, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './auth-sync.js';
16
16
  /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
17
17
  export function agentIdOf(spec) {
18
18
  return spec.split('@')[0].trim();
@@ -148,6 +148,13 @@ export function diffFleet(desired, probes, ctx) {
148
148
  if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
149
149
  rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
150
150
  }
151
+ else if (hasPortableAuthFiles(id) && !isCredentialSafeToPropagate(id)) {
152
+ // Single-use rotating refresh tokens (e.g. droid/WorkOS) must never be
153
+ // copied across boxes — the first refresh invalidates every other copy.
154
+ // Surface per-machine login guidance instead of silently propagating.
155
+ loginBlocked.push(id);
156
+ rowActions.push({ device: d.device, kind: 'needs-login', agent: id, detail: `${id} login uses a single-use rotating refresh token — log in on ${d.device} itself` });
157
+ }
151
158
  else if (isPropagatableAgent(id) &&
152
159
  (ctx.sourceAuth.bound.has(id) || (probe.platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(id)))) {
153
160
  // A login-propagation candidate we still can't push: the source token
@@ -27,6 +27,23 @@ interface AuthFileSpec {
27
27
  export declare const FLEET_AUTH_FILES: Record<string, AuthFileSpec[]>;
28
28
  /** Agents whose macOS credentials live in the ACL-bound login keychain. */
29
29
  export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
30
+ /**
31
+ * Agents whose OAuth credentials rely on single-use refresh tokens that rotate
32
+ * server-side on every exchange. Copying these credential files across machines
33
+ * is fatal: the first refresh on any box invalidates every other holder's token,
34
+ * collapsing the fleet to a single working login (droid/WorkOS collapsed 10 boxes
35
+ * to 1 overnight — RUSH-1958). Add any newly-discovered single-use-rotation
36
+ * harness here; the predicate below is the one place the propagation decision is
37
+ * made. See also `remote-login.ts` and `usage.ts` for the per-machine-login policy.
38
+ */
39
+ export declare const SINGLE_USE_ROTATING_REFRESH_AGENTS: ReadonlySet<string>;
40
+ /** True when `agent`'s portable credential file(s) are safe to copy between
41
+ * machines. Single-use rotating refresh tokens are never safe; keychain-bound
42
+ * tokens are handled separately by the caller via {@link KEYCHAIN_BOUND_ON_MAC}. */
43
+ export declare function isCredentialSafeToPropagate(agent: string): boolean;
44
+ /** True when the agent stores credentials in portable files we can read. This
45
+ * does NOT mean it is safe to propagate — check {@link isCredentialSafeToPropagate}. */
46
+ export declare function hasPortableAuthFiles(agent: string): boolean;
30
47
  /** Which agents `apply` can propagate auth for at all. */
31
48
  export declare function isPropagatableAgent(agent: string): boolean;
32
49
  /**
@@ -48,7 +65,7 @@ export interface LoginFlow {
48
65
  /**
49
66
  * The exact command that starts the login flow on the remote box, run as
50
67
  * `ssh -tt <box> <loginCommand>`. Bare `<cli>` for agents whose device flow
51
- * starts on launch (droid, kimi, gemini, antigravity), `<cli> login` for
68
+ * starts on launch (droid, kimi, antigravity), `<cli> login` for
52
69
  * codex/grok, `<cli> auth login` for opencode.
53
70
  */
54
71
  loginCommand: string;