@phnx-labs/agents-cli 1.20.93 → 1.21.0

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 (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Device/user config keys — typed read/write over the two-tier agents.yaml store.
3
+ *
4
+ * One registry (`CONFIG_KEYS`) maps each CLI dotted name to where it lives:
5
+ * - user scope → central `~/.agents/agents.yaml` under `config:` (syncs
6
+ * fleet-wide via `agents repo push/pull`)
7
+ * - device scope → `~/.agents/devices/<host>/agents.yaml` under `config:`
8
+ * (per-machine; mirrors how `defaultBrowserProfile` is routed)
9
+ *
10
+ * This machine's keys go through the readMeta/updateMeta funnel (state.ts) so the
11
+ * partition/overlay logic stays the single writer. Another device's doc is
12
+ * read/written in place — the devices/ tree syncs via the DotAgents repo, so
13
+ * editing `devices/mac-mini/agents.yaml` locally is how `configure`/`note`
14
+ * target a peer (`--device`-style).
15
+ *
16
+ * `browser.profile` is NOT a `config:` key — it is the existing
17
+ * `Meta.defaultBrowserProfile` field; the registry entry documents the mapping
18
+ * and set/get route to it so there is one source of truth (no duplicate key).
19
+ *
20
+ * Unset always means today's behavior.
21
+ */
22
+ import * as fs from 'fs';
23
+ import * as path from 'path';
24
+ import * as yaml from 'yaml';
25
+ import { META_HEADER, getUserAgentsDir, readMeta, updateMeta } from './state.js';
26
+ import { atomicWriteFileSync } from './fs-atomic.js';
27
+ import { machineId } from './machine-id.js';
28
+ import { assertValidDeviceName } from './devices/registry.js';
29
+ export const CONFIG_KEYS = [
30
+ {
31
+ name: 'interactive.host',
32
+ yamlKey: 'interactiveHost',
33
+ scope: 'user',
34
+ type: 'string',
35
+ description: 'Device that shows the user artifacts (browser opens, dashboards) — the "online macOS box" skills should use instead of guessing.',
36
+ validate: (v) => {
37
+ try {
38
+ assertValidDeviceName(v);
39
+ return null;
40
+ }
41
+ catch (err) {
42
+ return err?.message ?? String(err);
43
+ }
44
+ },
45
+ },
46
+ {
47
+ name: 'browser.profile',
48
+ yamlKey: 'defaultBrowserProfile',
49
+ scope: 'device',
50
+ type: 'string',
51
+ field: 'defaultBrowserProfile',
52
+ description: 'Browser profile `agents browser start` resolves to without --profile (set via `agents browser profiles set-default`).',
53
+ },
54
+ {
55
+ name: 'agents.max-concurrent',
56
+ yamlKey: 'maxAgents',
57
+ scope: 'device',
58
+ type: 'int',
59
+ description: 'Cap on concurrent agents on this device. What counts toward it depends on the consumer: ' +
60
+ 'Factory auto-launch counts device-wide running agents; teams placement counts the team’s own roster on the device.',
61
+ validate: (v) => (v >= 1 ? null : 'agents.max-concurrent must be >= 1.'),
62
+ },
63
+ {
64
+ name: 'scheduler.enabled',
65
+ yamlKey: 'schedulerEnabled',
66
+ scope: 'device',
67
+ type: 'bool',
68
+ description: 'Whether the routines scheduler (daemon) may fire on this device.',
69
+ },
70
+ {
71
+ name: 'notes',
72
+ yamlKey: 'notes',
73
+ scope: 'device',
74
+ type: 'string-list',
75
+ description: 'Free-form operator notes about this device (one entry per `agents devices note`).',
76
+ },
77
+ ];
78
+ /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
79
+ export function configKeySpec(name) {
80
+ const spec = CONFIG_KEYS.find((k) => k.name === name);
81
+ if (!spec) {
82
+ throw new Error(`Unknown config key '${name}'. Known keys: ${CONFIG_KEYS.map((k) => k.name).join(', ')}.`);
83
+ }
84
+ return spec;
85
+ }
86
+ /** Throw when `value` does not match the key's declared type or validation. */
87
+ function assertValidValue(spec, value) {
88
+ switch (spec.type) {
89
+ case 'string':
90
+ if (typeof value !== 'string' || value.length === 0) {
91
+ throw new Error(`Config key '${spec.name}' expects a non-empty string, got ${JSON.stringify(value)}.`);
92
+ }
93
+ break;
94
+ case 'int':
95
+ if (!Number.isInteger(value)) {
96
+ throw new Error(`Config key '${spec.name}' expects an integer, got ${JSON.stringify(value)}.`);
97
+ }
98
+ break;
99
+ case 'bool':
100
+ if (typeof value !== 'boolean') {
101
+ throw new Error(`Config key '${spec.name}' expects a boolean, got ${JSON.stringify(value)}.`);
102
+ }
103
+ break;
104
+ case 'string-list':
105
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
106
+ throw new Error(`Config key '${spec.name}' expects a list of strings, got ${JSON.stringify(value)}.`);
107
+ }
108
+ break;
109
+ }
110
+ const err = spec.validate?.(value);
111
+ if (err)
112
+ throw new Error(`Invalid value for '${spec.name}': ${err}`);
113
+ }
114
+ // ─── Sibling-device doc access ────────────────────────────────────────────────
115
+ /** Path to any device's doc (self or a peer) under the synced devices/ tree. */
116
+ function deviceDocPath(device) {
117
+ return path.join(getUserAgentsDir(), 'devices', device, 'agents.yaml');
118
+ }
119
+ /**
120
+ * Read a device doc directly. Returns null when the file does not exist. A
121
+ * malformed file is a hard error — silently returning null would let the next
122
+ * write wipe the device's pins/default (same contract as the device registry).
123
+ * A valid-but-non-map document (a bare string, a list) is the same kind of
124
+ * corruption: reject it here instead of failing later with a TypeError.
125
+ */
126
+ function readDeviceDoc(device) {
127
+ const p = deviceDocPath(device);
128
+ let raw;
129
+ try {
130
+ raw = fs.readFileSync(p, 'utf-8');
131
+ }
132
+ catch (err) {
133
+ if (err && err.code === 'ENOENT')
134
+ return null;
135
+ throw err;
136
+ }
137
+ const corrupted = (detail) => new Error(`Device config corrupted at ${p}: ${detail}. Inspect and restore from backup.`);
138
+ let parsed;
139
+ try {
140
+ parsed = yaml.parse(raw);
141
+ }
142
+ catch (err) {
143
+ throw corrupted(err?.message ?? String(err));
144
+ }
145
+ if (parsed === null || parsed === undefined)
146
+ return {};
147
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
148
+ throw corrupted(`expected a YAML map, got ${Array.isArray(parsed) ? 'a list' : JSON.stringify(parsed)}`);
149
+ }
150
+ return parsed;
151
+ }
152
+ /** Write a device doc in place (atomic). Only ever holds device-local fields. */
153
+ function writeDeviceDoc(device, doc) {
154
+ const p = deviceDocPath(device);
155
+ fs.mkdirSync(path.dirname(p), { recursive: true });
156
+ const body = Object.keys(doc).length > 0 ? doc : { agents: {} };
157
+ atomicWriteFileSync(p, META_HEADER + yaml.stringify(body));
158
+ }
159
+ // ─── Reads ────────────────────────────────────────────────────────────────────
160
+ function entryFromMeta(spec, meta) {
161
+ if (spec.field === 'defaultBrowserProfile') {
162
+ const value = meta.defaultBrowserProfile;
163
+ return { spec, value, layer: value !== undefined ? 'device' : undefined };
164
+ }
165
+ if (spec.scope === 'user') {
166
+ const value = meta.config?.[spec.yamlKey];
167
+ return { spec, value, layer: value !== undefined ? 'user' : undefined };
168
+ }
169
+ const value = meta.deviceConfig?.[spec.yamlKey];
170
+ return { spec, value, layer: value !== undefined ? 'device' : undefined };
171
+ }
172
+ /** True when `device` names this machine (case-insensitive, mirroring
173
+ * `isLocalDevice` in teams/scheduler.ts) — `configure ZION` on host zion must
174
+ * take the self path (readMeta/updateMeta funnel), not the peer-doc path. */
175
+ function isSelfDevice(device) {
176
+ return device.toLowerCase() === machineId();
177
+ }
178
+ /** Get one config key's value and the layer that set it. */
179
+ export function getConfigValue(name, opts) {
180
+ const spec = configKeySpec(name);
181
+ if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
182
+ const doc = readDeviceDoc(opts.device) ?? {};
183
+ return entryFromMeta(spec, { deviceConfig: doc.config, defaultBrowserProfile: doc.defaultBrowserProfile });
184
+ }
185
+ return entryFromMeta(spec, readMeta());
186
+ }
187
+ /** List every known key with its value and the layer that set it. */
188
+ export function listConfig(opts) {
189
+ return CONFIG_KEYS.map((spec) => getConfigValue(spec.name, opts));
190
+ }
191
+ // ─── Writes ───────────────────────────────────────────────────────────────────
192
+ function setInMeta(spec, value) {
193
+ updateMeta((m) => {
194
+ if (spec.field === 'defaultBrowserProfile') {
195
+ return { ...m, defaultBrowserProfile: value };
196
+ }
197
+ if (spec.scope === 'user') {
198
+ return { ...m, config: { ...m.config, [spec.yamlKey]: value } };
199
+ }
200
+ return { ...m, deviceConfig: { ...m.deviceConfig, [spec.yamlKey]: value } };
201
+ });
202
+ }
203
+ function unsetInMeta(spec) {
204
+ updateMeta((m) => {
205
+ if (spec.field === 'defaultBrowserProfile') {
206
+ const { defaultBrowserProfile, ...rest } = m;
207
+ void defaultBrowserProfile;
208
+ return rest;
209
+ }
210
+ const block = spec.scope === 'user' ? m.config : m.deviceConfig;
211
+ if (!block || !(spec.yamlKey in block))
212
+ return m;
213
+ const next = { ...block };
214
+ delete next[spec.yamlKey];
215
+ const cleaned = Object.keys(next).length > 0 ? next : undefined;
216
+ return spec.scope === 'user' ? { ...m, config: cleaned } : { ...m, deviceConfig: cleaned };
217
+ });
218
+ }
219
+ function setInDeviceDoc(device, spec, value) {
220
+ const doc = readDeviceDoc(device) ?? {};
221
+ if (spec.field === 'defaultBrowserProfile') {
222
+ doc.defaultBrowserProfile = value;
223
+ }
224
+ else {
225
+ doc.config = { ...doc.config, [spec.yamlKey]: value };
226
+ }
227
+ writeDeviceDoc(device, doc);
228
+ }
229
+ function unsetInDeviceDoc(device, spec) {
230
+ const doc = readDeviceDoc(device);
231
+ if (!doc)
232
+ return; // nothing stored — unset is a no-op
233
+ if (spec.field === 'defaultBrowserProfile') {
234
+ delete doc.defaultBrowserProfile;
235
+ }
236
+ else if (doc.config && spec.yamlKey in doc.config) {
237
+ delete doc.config[spec.yamlKey];
238
+ if (Object.keys(doc.config).length === 0)
239
+ delete doc.config;
240
+ }
241
+ else {
242
+ return; // key not present — no write needed
243
+ }
244
+ writeDeviceDoc(device, doc);
245
+ }
246
+ /** Set a config key (validated). Device-scope keys target this machine unless `opts.device` names a peer. */
247
+ export function setConfigValue(name, value, opts) {
248
+ const spec = configKeySpec(name);
249
+ assertValidValue(spec, value);
250
+ if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
251
+ setInDeviceDoc(opts.device, spec, value);
252
+ return;
253
+ }
254
+ setInMeta(spec, value);
255
+ }
256
+ /** Unset a config key — restores default behavior. No-op when already unset. */
257
+ export function unsetConfigValue(name, opts) {
258
+ const spec = configKeySpec(name);
259
+ if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
260
+ unsetInDeviceDoc(opts.device, spec);
261
+ return;
262
+ }
263
+ unsetInMeta(spec);
264
+ }
265
+ // ─── Consumers' helpers ───────────────────────────────────────────────────────
266
+ /** True unless this machine's device doc disables the routines scheduler. */
267
+ export function isSchedulerEnabled() {
268
+ return getConfigValue('scheduler.enabled').value !== false;
269
+ }
270
+ /**
271
+ * Throw when the routines scheduler is disabled on this machine, naming the
272
+ * setting and the fix. The single message every scheduler-start surface
273
+ * (auto-start on `routines add`, manual `routines start`, the daemon's own
274
+ * scheduler init) refuses with.
275
+ */
276
+ export function assertSchedulerEnabled() {
277
+ if (isSchedulerEnabled())
278
+ return;
279
+ throw new Error(`The routines scheduler is disabled on this device (scheduler.enabled=false in ~/.agents/devices/${machineId()}/agents.yaml). ` +
280
+ `Re-enable with: agents devices configure ${machineId()} --scheduler on`);
281
+ }
282
+ /**
283
+ * Read the `agents.max-concurrent` cap for each named device from its synced
284
+ * device doc (no SSH). Devices without a cap are omitted — uncapped is the
285
+ * default. Used as an input to host ranking (teams placement, Factory
286
+ * auto-launch), never as a remote probe.
287
+ */
288
+ export function readMaxConcurrentCaps(devices) {
289
+ const caps = {};
290
+ for (const device of devices) {
291
+ const value = getConfigValue('agents.max-concurrent', { device }).value;
292
+ if (typeof value === 'number')
293
+ caps[device] = value;
294
+ }
295
+ return caps;
296
+ }
@@ -0,0 +1,19 @@
1
+ import { type ActorKind } from './actor.js';
2
+ export interface EventProvenance {
3
+ osUser: string;
4
+ transport: 'local' | 'ssh';
5
+ sshClientIp?: string;
6
+ actor: string;
7
+ kind: ActorKind;
8
+ machineId: string;
9
+ sessionId?: string;
10
+ agent?: string;
11
+ launchId?: string;
12
+ parentSessionId?: string;
13
+ }
14
+ /**
15
+ * Stamp the shared identity floor used by both operational and activity events.
16
+ * Explicit event payload fields may override these defaults at the call site.
17
+ */
18
+ export declare function stampProvenance(env?: NodeJS.ProcessEnv): EventProvenance;
19
+ export declare function resetEventProvenanceForTest(): void;
@@ -0,0 +1,48 @@
1
+ import * as os from 'os';
2
+ import { resolveActor } from './actor.js';
3
+ import { machineId } from './machine-id.js';
4
+ import { parseSshConnection } from './session/provenance.js';
5
+ let cachedOrigin;
6
+ let cachedDeviceId;
7
+ /**
8
+ * Stamp the shared identity floor used by both operational and activity events.
9
+ * Explicit event payload fields may override these defaults at the call site.
10
+ */
11
+ export function stampProvenance(env = process.env) {
12
+ if (!cachedOrigin) {
13
+ let osUser = 'unknown';
14
+ try {
15
+ osUser = os.userInfo().username;
16
+ }
17
+ catch {
18
+ // A uid without a passwd entry has no attributable OS user.
19
+ }
20
+ const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
21
+ const actor = resolveActor();
22
+ cachedOrigin = {
23
+ osUser,
24
+ transport: ssh ? 'ssh' : 'local',
25
+ ...(ssh ? { sshClientIp: ssh.clientIp } : {}),
26
+ actor: actor.id,
27
+ kind: actor.kind,
28
+ };
29
+ }
30
+ const provenance = {
31
+ ...cachedOrigin,
32
+ machineId: (cachedDeviceId ??= machineId()),
33
+ };
34
+ const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
35
+ if (sessionId)
36
+ provenance.sessionId = sessionId;
37
+ if (env.AGENTS_AGENT_NAME)
38
+ provenance.agent = env.AGENTS_AGENT_NAME;
39
+ if (env.AGENT_LAUNCH_ID)
40
+ provenance.launchId = env.AGENT_LAUNCH_ID;
41
+ if (env.AGENTS_PARENT_SESSION_ID)
42
+ provenance.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
43
+ return provenance;
44
+ }
45
+ export function resetEventProvenanceForTest() {
46
+ cachedOrigin = undefined;
47
+ cachedDeviceId = undefined;
48
+ }
@@ -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
- import { type ActorKind } from './actor.js';
14
+ import type { ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
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.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | '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' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.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' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
@@ -46,7 +46,7 @@ export interface EventMeta {
46
46
  /** Resolved actor id — which human/agent is behind this event (RUSH-2020). */
47
47
  actor?: string;
48
48
  /** Actor kind (`human`/`agent`). */
49
- kind?: ActorKind;
49
+ kind?: ActorKind | 'unknown';
50
50
  }
51
51
  export interface EventPayload {
52
52
  agent?: string;
@@ -16,11 +16,9 @@ import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { gzipSync, gunzipSync } from 'node:zlib';
19
- import { parseSshConnection } from './session/provenance.js';
20
19
  import { ensureLockTarget, withFileLock } from './fs-atomic.js';
21
20
  import { getUserAgentsDir } from './state.js';
22
- import { resolveActor } from './actor.js';
23
- import { machineId } from './machine-id.js';
21
+ import { stampProvenance, resetEventProvenanceForTest } from './event-provenance.js';
24
22
  /** Lazy perf warehouse write — avoids a hard cycle at module load. */
25
23
  function recordPerfTiming(payload) {
26
24
  try {
@@ -329,51 +327,7 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
329
327
  }
330
328
  return { kind: stdoutIsTTY ? 'terminal' : 'script' };
331
329
  }
332
- /**
333
- * Who is running this process and from where. Derived once per process from the
334
- * OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
335
- * cached — provenance can't change mid-process, so every emit() pays for it once.
336
- */
337
- let _origin;
338
- function auditOrigin() {
339
- if (_origin)
340
- return _origin;
341
- let osUser = 'unknown';
342
- try {
343
- osUser = os.userInfo().username;
344
- }
345
- catch {
346
- // Container/edge cases where the uid has no passwd entry.
347
- }
348
- const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
349
- const actor = resolveActor();
350
- _origin = {
351
- osUser,
352
- transport: ssh ? 'ssh' : 'local',
353
- ...(ssh ? { sshClientIp: ssh.clientIp } : {}),
354
- actor: actor.id,
355
- kind: actor.kind,
356
- };
357
- return _origin;
358
- }
359
- /** This machine's normalized device id, resolved once — it can't change mid-process. */
360
- let _machineId;
361
- function cachedMachineId() {
362
- return (_machineId ??= machineId());
363
- }
364
- function resolveProvenance(env = process.env) {
365
- const p = {};
366
- const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
367
- if (sessionId)
368
- p.sessionId = sessionId;
369
- if (env.AGENTS_AGENT_NAME)
370
- p.agent = env.AGENTS_AGENT_NAME;
371
- if (env.AGENT_LAUNCH_ID)
372
- p.launchId = env.AGENT_LAUNCH_ID;
373
- if (env.AGENTS_PARENT_SESSION_ID)
374
- p.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
375
- return p;
376
- }
330
+ // ─── Audit attribution ────────────────────────────────────────────────────────
377
331
  // ─── Core API ─────────────────────────────────────────────────────────────────
378
332
  /**
379
333
  * Emit a structured event to the append-only audit log.
@@ -396,13 +350,12 @@ export function emit(event, payload = {}, overrides = {}) {
396
350
  const safePayload = sanitizePayload(payload);
397
351
  const record = {
398
352
  // Provenance floor first: env-sourced defaults an explicit payload overrides.
399
- ...resolveProvenance(),
353
+ ...stampProvenance(),
400
354
  ...safePayload,
401
355
  ts: overrides.ts ?? new Date().toISOString(),
402
356
  tz: getTimezoneOffset(),
403
357
  tzName: getTimezoneName(),
404
358
  hostname: os.hostname(),
405
- machineId: cachedMachineId(),
406
359
  platform: os.platform(),
407
360
  arch: os.arch(),
408
361
  pid: process.pid,
@@ -411,7 +364,6 @@ export function emit(event, payload = {}, overrides = {}) {
411
364
  level: levelFor(event),
412
365
  caller: caller.kind,
413
366
  ...(caller.session ? { session: caller.session } : {}),
414
- ...auditOrigin(),
415
367
  };
416
368
  const line = JSON.stringify(record) + '\n';
417
369
  const logPath = eventsPath();
@@ -919,8 +871,7 @@ export function getLogsPath() {
919
871
  }
920
872
  export function _resetForTest(overrideEventsPath) {
921
873
  _eventsPath = overrideEventsPath;
922
- _origin = undefined;
923
- _machineId = undefined;
874
+ resetEventProvenanceForTest();
924
875
  _chmoddedPath = undefined;
925
876
  lastRotationCheck = 0;
926
877
  }
@@ -12,7 +12,7 @@ export interface MessageReceipt {
12
12
  /** The message id this receipt describes. */
13
13
  msgId: string;
14
14
  /** Delivery lifecycle state. */
15
- status: 'queued' | 'consumed' | 'continued';
15
+ status: 'queued' | 'consumed' | 'continued' | 'dropped' | 'expired';
16
16
  /** ISO-8601 timestamp of the state transition. */
17
17
  at: string;
18
18
  /** Optional sender label for the message. */
package/dist/lib/feed.js CHANGED
@@ -153,6 +153,8 @@ const RECEIPT_STATUS_RANK = {
153
153
  queued: 0,
154
154
  consumed: 1,
155
155
  continued: 2,
156
+ dropped: 3,
157
+ expired: 3,
156
158
  };
157
159
  /**
158
160
  * Record a delivery-receipt transition for a message tied to a block.
package/dist/lib/git.d.ts CHANGED
@@ -41,8 +41,12 @@ export declare function assertValidBranchName(branch: string): void;
41
41
  *
42
42
  * Prefer this over `git.push(remote, branch)` whenever the branch comes from
43
43
  * repo state rather than a hard-coded literal.
44
+ *
45
+ * Pass `targetBranch` to push the local `branch` to a differently-named remote
46
+ * branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
47
+ * working tree to a branch other than the checked-out one.
44
48
  */
45
- export declare function pushOrigin(git: SimpleGit, branch: string): Promise<void>;
49
+ export declare function pushOrigin(git: SimpleGit, branch: string, targetBranch?: string): Promise<void>;
46
50
  /** Parsed representation of a git source string (GitHub, generic URL, or local path). */
47
51
  export interface GitSource {
48
52
  type: 'github' | 'url' | 'local';
@@ -124,6 +128,8 @@ export declare function resolveGitHubUsername(): Promise<string | null>;
124
128
  * Get the remote URL for origin in a git repo.
125
129
  */
126
130
  export declare function getRemoteUrl(repoPath: string): Promise<string | null>;
131
+ /** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
132
+ export declare function getCurrentBranch(repoPath: string): Promise<string>;
127
133
  /**
128
134
  * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
129
135
  * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
@@ -158,8 +164,13 @@ export type CommitAndPushResult = {
158
164
  * Clean tree + local ahead of origin still pushes — "nothing to commit" is not
159
165
  * "nothing to push". Reports "already up to date" only when `ahead === 0` and
160
166
  * there is nothing to commit.
167
+ *
168
+ * `targetBranch` pushes the working tree to a differently-named remote branch
169
+ * (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
170
+ * callers that print a branch-scoped URL reference where the commit actually
171
+ * landed — not the checked-out branch.
161
172
  */
162
- export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
173
+ export declare function commitAndPush(repoPath: string, message: string, targetBranch?: string): Promise<CommitAndPushResult>;
163
174
  /**
164
175
  * Check if repo has uncommitted changes.
165
176
  */
package/dist/lib/git.js CHANGED
@@ -77,9 +77,18 @@ export function assertValidBranchName(branch) {
77
77
  *
78
78
  * Prefer this over `git.push(remote, branch)` whenever the branch comes from
79
79
  * repo state rather than a hard-coded literal.
80
+ *
81
+ * Pass `targetBranch` to push the local `branch` to a differently-named remote
82
+ * branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
83
+ * working tree to a branch other than the checked-out one.
80
84
  */
81
- export async function pushOrigin(git, branch) {
85
+ export async function pushOrigin(git, branch, targetBranch) {
82
86
  assertValidBranchName(branch);
87
+ if (targetBranch && targetBranch !== branch) {
88
+ assertValidBranchName(targetBranch);
89
+ await git.raw(['push', '--', 'origin', `${branch}:${targetBranch}`]);
90
+ return;
91
+ }
83
92
  await git.raw(['push', '--', 'origin', branch]);
84
93
  }
85
94
  /**
@@ -435,6 +444,16 @@ export async function getRemoteUrl(repoPath) {
435
444
  return null;
436
445
  }
437
446
  }
447
+ /** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
448
+ export async function getCurrentBranch(repoPath) {
449
+ try {
450
+ const status = await simpleGit(repoPath).status();
451
+ return status.current || 'main';
452
+ }
453
+ catch {
454
+ return 'main';
455
+ }
456
+ }
438
457
  /**
439
458
  * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
440
459
  * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
@@ -494,13 +513,23 @@ export async function checkGitHubRepoExists(owner, repo) {
494
513
  * Clean tree + local ahead of origin still pushes — "nothing to commit" is not
495
514
  * "nothing to push". Reports "already up to date" only when `ahead === 0` and
496
515
  * there is nothing to commit.
516
+ *
517
+ * `targetBranch` pushes the working tree to a differently-named remote branch
518
+ * (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
519
+ * callers that print a branch-scoped URL reference where the commit actually
520
+ * landed — not the checked-out branch.
497
521
  */
498
- export async function commitAndPush(repoPath, message) {
522
+ export async function commitAndPush(repoPath, message, targetBranch) {
499
523
  try {
500
524
  const git = simpleGit(repoPath);
501
525
  let status = await git.status();
502
526
  const branch = status.current || 'main';
503
527
  assertValidBranchName(branch);
528
+ if (targetBranch)
529
+ assertValidBranchName(targetBranch);
530
+ // The branch the commit ends up on remotely — the checked-out branch unless
531
+ // an explicit target was requested.
532
+ const pushedBranch = targetBranch || branch;
504
533
  let committed = false;
505
534
  if (status.files.length > 0) {
506
535
  await git.add('-A');
@@ -509,7 +538,10 @@ export async function commitAndPush(repoPath, message) {
509
538
  status = await git.status();
510
539
  }
511
540
  const ahead = status.ahead ?? 0;
512
- if (!committed && ahead === 0) {
541
+ // A same-branch push short-circuits when there is nothing new; a push to a
542
+ // different target branch must still run even from a clean, non-ahead tree,
543
+ // since the target may not carry these commits yet.
544
+ if (!committed && ahead === 0 && pushedBranch === branch) {
513
545
  return {
514
546
  success: true,
515
547
  detail: 'already up to date',
@@ -521,12 +553,12 @@ export async function commitAndPush(repoPath, message) {
521
553
  // Capture remote tip before push for a real ref range in the detail string.
522
554
  let before = '';
523
555
  try {
524
- before = (await git.raw(['rev-parse', '--short=8', `origin/${branch}`])).trim();
556
+ before = (await git.raw(['rev-parse', '--short=8', `origin/${pushedBranch}`])).trim();
525
557
  }
526
558
  catch {
527
559
  /* origin/<branch> may not exist yet (first push) */
528
560
  }
529
- await pushOrigin(git, branch);
561
+ await pushOrigin(git, branch, targetBranch);
530
562
  let after = '';
531
563
  try {
532
564
  after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
@@ -547,7 +579,7 @@ export async function commitAndPush(repoPath, message) {
547
579
  return {
548
580
  success: true,
549
581
  detail,
550
- branch,
582
+ branch: pushedBranch,
551
583
  committed,
552
584
  pushed: true,
553
585
  };