@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Delivery envelope for `agents send` / `agents notify`.
3
+ *
4
+ * One primitive: resolve a destination (channel + target), compose text + urls +
5
+ * attachments, hand off to a channel provider. `notify` is the same path with
6
+ * destination defaulted to `notify.owner` in agents.yaml — owner is an address
7
+ * alias (`--to owner`), not a separate stack.
8
+ *
9
+ * Agent control (`agents message`, `sessions inject`) stays outside this module.
10
+ */
11
+ import type { Meta } from '../types.js';
12
+ import type { SendResult } from './registry.js';
13
+ /** Normalized delivery request after CLI/config resolution. */
14
+ export interface SendEnvelope {
15
+ text: string;
16
+ channel: string;
17
+ to: string;
18
+ thread?: string;
19
+ attachments?: string[];
20
+ from?: string;
21
+ dryRun?: boolean;
22
+ }
23
+ export interface ResolveSendInput {
24
+ /**
25
+ * Body text. Prefer `--text`; positional `[text]` is accepted for compat and
26
+ * folded in when `--text` is omitted.
27
+ */
28
+ text?: string;
29
+ /** Positional `[text]` from commander (legacy). */
30
+ positionalText?: string;
31
+ /**
32
+ * Recipient. Channel-specific id, or the alias `owner` which expands to
33
+ * `notify.owner.{channel,to}`.
34
+ */
35
+ to?: string;
36
+ /** Provider/channel name. Required unless `to` is `owner` (or ownerMode). */
37
+ channel?: string;
38
+ thread?: string;
39
+ /** Local file paths. */
40
+ attachments?: string[];
41
+ /** Links / remote media refs — appended to the body so every provider sees them. */
42
+ urls?: string[];
43
+ from?: string;
44
+ dryRun?: boolean;
45
+ /**
46
+ * When true (`agents notify`), missing channel/to default to `notify.owner`.
47
+ * Explicit flags still win.
48
+ */
49
+ ownerMode?: boolean;
50
+ }
51
+ export type ResolveSendResult = {
52
+ ok: true;
53
+ envelope: SendEnvelope;
54
+ } | {
55
+ ok: false;
56
+ error: string;
57
+ };
58
+ /** True when the destination token means “the configured owner”. */
59
+ export declare function isOwnerAlias(to: string | undefined): boolean;
60
+ /** Compose body + optional URL lines (skip urls already present in the body). */
61
+ export declare function composeSendText(text: string, urls?: string[]): string;
62
+ /** Read notify.owner; null when either field is missing. */
63
+ export declare function readOwnerDest(meta: Meta): {
64
+ channel: string;
65
+ to: string;
66
+ } | null;
67
+ /**
68
+ * Resolve CLI/config into a send envelope. Pure except for reading `meta` —
69
+ * no I/O, no provider registration — so unit tests do not need a real PATH.
70
+ */
71
+ export declare function resolveSendEnvelope(input: ResolveSendInput, meta: Meta): ResolveSendResult;
72
+ /**
73
+ * Register providers, resolve transport, deliver. Used by the CLI and by any
74
+ * internal caller that already has a resolved envelope.
75
+ */
76
+ export declare function deliverEnvelope(envelope: SendEnvelope, meta: Meta): Promise<SendResult>;
77
+ /** Resolve + deliver in one step (CLI happy path). */
78
+ export declare function sendMessage(input: ResolveSendInput, meta: Meta): Promise<{
79
+ result: SendResult;
80
+ envelope: SendEnvelope;
81
+ } | {
82
+ error: string;
83
+ }>;
@@ -0,0 +1,112 @@
1
+ import { registerBuiltinProviders } from './providers/index.js';
2
+ import { resolveTransport } from './resolve.js';
3
+ const OWNER_ALIAS = 'owner';
4
+ /** True when the destination token means “the configured owner”. */
5
+ export function isOwnerAlias(to) {
6
+ return (to ?? '').trim().toLowerCase() === OWNER_ALIAS;
7
+ }
8
+ /** Compose body + optional URL lines (skip urls already present in the body). */
9
+ export function composeSendText(text, urls) {
10
+ const body = text.trim();
11
+ const extra = (urls ?? [])
12
+ .map((u) => u.trim())
13
+ .filter(Boolean)
14
+ .filter((u) => !body.includes(u));
15
+ if (extra.length === 0)
16
+ return body;
17
+ return body ? `${body}\n${extra.join('\n')}` : extra.join('\n');
18
+ }
19
+ /** Read notify.owner; null when either field is missing. */
20
+ export function readOwnerDest(meta) {
21
+ const owner = meta.notify?.owner;
22
+ const channel = owner?.channel?.trim();
23
+ const to = owner?.to?.trim();
24
+ if (!channel || !to)
25
+ return null;
26
+ return { channel, to };
27
+ }
28
+ /**
29
+ * Resolve CLI/config into a send envelope. Pure except for reading `meta` —
30
+ * no I/O, no provider registration — so unit tests do not need a real PATH.
31
+ */
32
+ export function resolveSendEnvelope(input, meta) {
33
+ const positional = (input.positionalText ?? '').trim();
34
+ const flagged = (input.text ?? '').trim();
35
+ if (positional && flagged && positional !== flagged) {
36
+ return {
37
+ ok: false,
38
+ error: 'Pass the message once: use --text, or a positional argument, not both with different values.',
39
+ };
40
+ }
41
+ const rawText = flagged || positional;
42
+ const urls = (input.urls ?? []).map((u) => u.trim()).filter(Boolean);
43
+ const text = composeSendText(rawText, urls);
44
+ if (!text) {
45
+ return {
46
+ ok: false,
47
+ error: 'Message is empty. Pass --text "…", a positional message, and/or --url.',
48
+ };
49
+ }
50
+ // Owner defaults fill only missing fields (and expand the bare "owner" alias).
51
+ // Explicit --channel/--to always win; a complete notify.owner is NOT required
52
+ // when both flags are already set (same merge-then-require shape as main).
53
+ const ownerCfg = meta.notify?.owner;
54
+ const ownerChannel = ownerCfg?.channel?.trim() || '';
55
+ const ownerTo = ownerCfg?.to?.trim() || '';
56
+ let channel = (input.channel ?? '').trim();
57
+ let to = (input.to ?? '').trim();
58
+ const usedOwnerAlias = isOwnerAlias(to);
59
+ if (input.ownerMode || usedOwnerAlias) {
60
+ if (!channel)
61
+ channel = ownerChannel;
62
+ if (!to || usedOwnerAlias)
63
+ to = ownerTo;
64
+ }
65
+ if (!channel || !to) {
66
+ const hint = input.ownerMode || usedOwnerAlias
67
+ ? 'Set notify.owner.{channel,to} in agents.yaml, or pass --channel and --to explicitly.'
68
+ : 'Need --channel and --to (or --to owner with notify.owner configured). ' +
69
+ 'Example: agents send --channel desktop --to local --text "hi"';
70
+ return { ok: false, error: hint };
71
+ }
72
+ const attachments = [
73
+ ...(input.attachments ?? []),
74
+ ]
75
+ .map((p) => p.trim())
76
+ .filter(Boolean);
77
+ return {
78
+ ok: true,
79
+ envelope: {
80
+ text,
81
+ channel,
82
+ to,
83
+ thread: input.thread?.trim() || undefined,
84
+ attachments: attachments.length ? attachments : undefined,
85
+ from: input.from?.trim() || undefined,
86
+ dryRun: input.dryRun,
87
+ },
88
+ };
89
+ }
90
+ /**
91
+ * Register providers, resolve transport, deliver. Used by the CLI and by any
92
+ * internal caller that already has a resolved envelope.
93
+ */
94
+ export async function deliverEnvelope(envelope, meta) {
95
+ registerBuiltinProviders();
96
+ const provider = resolveTransport(envelope.channel, meta);
97
+ return provider.send(envelope.text, {
98
+ target: envelope.to,
99
+ thread: envelope.thread,
100
+ attachments: envelope.attachments,
101
+ from: envelope.from,
102
+ dryRun: envelope.dryRun,
103
+ });
104
+ }
105
+ /** Resolve + deliver in one step (CLI happy path). */
106
+ export async function sendMessage(input, meta) {
107
+ const resolved = resolveSendEnvelope(input, meta);
108
+ if (!resolved.ok)
109
+ return { error: resolved.error };
110
+ const result = await deliverEnvelope(resolved.envelope, meta);
111
+ return { result, envelope: resolved.envelope };
112
+ }
@@ -80,6 +80,20 @@ export interface DeviceProfile {
80
80
  export declare function deviceRole(d: DeviceProfile): DeviceRole;
81
81
  /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
82
82
  export declare function isControlDevice(d: DeviceProfile): boolean;
83
+ /**
84
+ * Whether a fan-out should dial this device, honouring the preference stated on
85
+ * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
86
+ * {@link DeviceTailscale.online} snapshot.
87
+ *
88
+ * Reading only `tailscale.online` is wrong in both directions, and both were
89
+ * live on a real fleet. A `via:"manual"` device never gets a tailscale peer
90
+ * entry at all, so its `online` is permanently `undefined` and a strict
91
+ * `=== true` test skipped it forever — every session on that box was invisible
92
+ * to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
93
+ * a stale `online:true` and gets dialed, burning a full ConnectTimeout and
94
+ * reporting a false "unreachable" that callers treat as doubt.
95
+ */
96
+ export declare function isDialableDevice(d: DeviceProfile): boolean;
83
97
  /** Map of device name to profile. */
84
98
  export type DeviceRegistry = Record<string, DeviceProfile>;
85
99
  /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
@@ -27,6 +27,43 @@ export function deviceRole(d) {
27
27
  export function isControlDevice(d) {
28
28
  return deviceRole(d) === 'control';
29
29
  }
30
+ /**
31
+ * Whether a fan-out should dial this device, honouring the preference stated on
32
+ * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
33
+ * {@link DeviceTailscale.online} snapshot.
34
+ *
35
+ * Reading only `tailscale.online` is wrong in both directions, and both were
36
+ * live on a real fleet. A `via:"manual"` device never gets a tailscale peer
37
+ * entry at all, so its `online` is permanently `undefined` and a strict
38
+ * `=== true` test skipped it forever — every session on that box was invisible
39
+ * to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
40
+ * a stale `online:true` and gets dialed, burning a full ConnectTimeout and
41
+ * reporting a false "unreachable" that callers treat as doubt.
42
+ */
43
+ export function isDialableDevice(d) {
44
+ // Union, deliberately: either signal saying "go" is enough. A probe may only
45
+ // ADD a peer to the sweep, never remove one.
46
+ //
47
+ // The probe is not trustworthy enough to exclude on. It runs with a short SSH
48
+ // budget, so on a congested tailnet it returns false negatives — observed
49
+ // marking the LOCAL machine unreachable, and flipping a live worker box from
50
+ // reachable to unreachable nine minutes apart. Letting that shrink the sweep
51
+ // would hide sessions on healthy boxes, a worse failure than the one below.
52
+ //
53
+ // The snapshot alone is not enough either: a device registered with
54
+ // `address.via: "manual"` never gets a tailscale peer entry, so `online`
55
+ // stays undefined and a strict `=== true` test skipped it forever, making
56
+ // every session on that box unresolvable from any other machine.
57
+ //
58
+ // So: no tailscale block at all is unknown-not-offline (the rule `ssh.ts`
59
+ // renderDeviceTable and Factory's `isDeviceOnline` already use, so the picker
60
+ // and the sweep agree on who exists), and a positive probe rescues a device
61
+ // whose snapshot says offline. The cost of dialing a box that is actually
62
+ // asleep is one ConnectTimeout — the pre-existing behaviour, not a regression.
63
+ if (d.reachability?.reachable)
64
+ return true;
65
+ return !d.tailscale || d.tailscale.online === true;
66
+ }
30
67
  function registryPath() {
31
68
  return getDevicesRegistryPath();
32
69
  }
@@ -0,0 +1,46 @@
1
+ export interface IngestReject {
2
+ /** 1-based index of the offending line within the batch. */
3
+ line: number;
4
+ reason: string;
5
+ }
6
+ export interface IngestResult {
7
+ written: number;
8
+ rejected: IngestReject[];
9
+ /** Per-store counts, so a caller/test can assert routing without reading files. */
10
+ routed: {
11
+ operational: number;
12
+ activity: number;
13
+ };
14
+ }
15
+ export interface IngestOptions {
16
+ /**
17
+ * Producer name, stamped as `module` on operational records so
18
+ * `agents events --module <source>` filters to this producer. Required: an
19
+ * unattributed external event is not auditable.
20
+ */
21
+ source: string;
22
+ /** Validate and report without writing either store. */
23
+ dryRun?: boolean;
24
+ /** Override the activity root (tests). */
25
+ activityRoot?: string;
26
+ }
27
+ /**
28
+ * Route one already-validated line.
29
+ *
30
+ * The rule is forced by the stores, not chosen:
31
+ * - The activity store is keyed by session id ON DISK (one file per session),
32
+ * so an event with no session has nowhere to live there.
33
+ * - Activity records are read back with `module: 'activity'` hardcoded, so
34
+ * anything that must stay filterable by its producer has to be operational.
35
+ * Therefore: milestone + usable sessionId -> activity; everything else -> ops.
36
+ * `readUnifiedEvents` merges the two at read time, so `agents events` sees both.
37
+ */
38
+ export declare function routeFor(event: string, sessionId: unknown): 'activity' | 'operational';
39
+ /**
40
+ * Ingest a JSONL batch.
41
+ *
42
+ * Rejection is PER LINE and lossless: a single bad line never discards the rest
43
+ * of the batch. One typo in a 100-event flush must not silently drop 99 real
44
+ * events, and the caller still learns exactly which line failed and why.
45
+ */
46
+ export declare function ingestBatch(input: string, opts: IngestOptions): IngestResult;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Ingest events produced OUTSIDE this process — the writer behind
3
+ * `agents events emit`.
4
+ *
5
+ * Why this exists: `emit()` (lib/events.ts) and `appendActivityEvent()`
6
+ * (lib/activity.ts) are in-process APIs, but the producers that most need to
7
+ * record events are not agents-cli processes at all — the Factory VS Code
8
+ * extension host, a shell guard, any external tool. They shell out instead, and
9
+ * this module is the one place that turns their JSONL into real records.
10
+ *
11
+ * It deliberately does NOT reuse `feed post`: that surface hardcodes the
12
+ * `status.posted` kind, infers identity by walking the pid-registry ancestor
13
+ * chain (wrong for a process that is not a descendant of an agent), throws when
14
+ * that inference fails, and fires the configured broadcast sinks. A telemetry
15
+ * path must do none of those things.
16
+ *
17
+ * Pure except for the two writers it calls, so the routing and validation rules
18
+ * below are unit-testable against a temp events path + activity root.
19
+ */
20
+ import { emit, isEventType, } from './events.js';
21
+ import { appendActivityEvent, tierForEvent, } from './activity.js';
22
+ /** Envelope keys an incoming line may set directly; everything else is payload. */
23
+ const ENVELOPE_KEYS = [
24
+ 'event', 'ts', 'sessionId', 'mailboxId', 'terminalId', 'launchId', 'tmuxPane',
25
+ 'host', 'runtime', 'agent', 'tool', 'detail', 'url', 'project', 'cwd',
26
+ ];
27
+ const ENVELOPE_KEY_SET = new Set(ENVELOPE_KEYS);
28
+ /** ISO-8601 with at least seconds. Rejects "now", epoch ints, and garbage. */
29
+ function isIsoTimestamp(value) {
30
+ if (typeof value !== 'string')
31
+ return false;
32
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value))
33
+ return false;
34
+ return !Number.isNaN(Date.parse(value));
35
+ }
36
+ /**
37
+ * A session id must be non-empty AND survive the activity writer's filename
38
+ * sanitizer — `activityPath` throws on an id that reduces to nothing, and that
39
+ * throw would abort a whole batch mid-write. Check it here so the line is
40
+ * rejected cleanly and its siblings still land.
41
+ */
42
+ function isUsableSessionId(value) {
43
+ return typeof value === 'string' && value.replace(/[^A-Za-z0-9._-]/g, '-').replace(/-+/g, '') !== '';
44
+ }
45
+ /**
46
+ * Route one already-validated line.
47
+ *
48
+ * The rule is forced by the stores, not chosen:
49
+ * - The activity store is keyed by session id ON DISK (one file per session),
50
+ * so an event with no session has nowhere to live there.
51
+ * - Activity records are read back with `module: 'activity'` hardcoded, so
52
+ * anything that must stay filterable by its producer has to be operational.
53
+ * Therefore: milestone + usable sessionId -> activity; everything else -> ops.
54
+ * `readUnifiedEvents` merges the two at read time, so `agents events` sees both.
55
+ */
56
+ export function routeFor(event, sessionId) {
57
+ return tierForEvent(event) === 'milestone' && isUsableSessionId(sessionId) ? 'activity' : 'operational';
58
+ }
59
+ /** Validate one raw JSONL line. Returns either a parsed line or a rejection. */
60
+ function parseLine(raw, lineNo) {
61
+ let obj;
62
+ try {
63
+ obj = JSON.parse(raw);
64
+ }
65
+ catch {
66
+ return { line: lineNo, reason: 'not valid JSON' };
67
+ }
68
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
69
+ return { line: lineNo, reason: 'not a JSON object' };
70
+ }
71
+ const rec = obj;
72
+ const event = rec.event;
73
+ if (typeof event !== 'string' || event === '') {
74
+ return { line: lineNo, reason: 'missing "event"' };
75
+ }
76
+ if (!isEventType(event)) {
77
+ return { line: lineNo, reason: `unknown event kind: ${event}` };
78
+ }
79
+ if (rec.ts !== undefined && !isIsoTimestamp(rec.ts)) {
80
+ return { line: lineNo, reason: `invalid "ts" (want ISO-8601): ${String(rec.ts)}` };
81
+ }
82
+ // A milestone with no usable session id is REJECTED, not quietly demoted to
83
+ // the operational store. Silently writing it somewhere else would look like
84
+ // success while the event never appears in the activity lane the producer
85
+ // asked for -- exactly the "wrong path that looks like success" this codebase
86
+ // forbids at a boundary.
87
+ if (tierForEvent(event) === 'milestone' && !isUsableSessionId(rec.sessionId)) {
88
+ return {
89
+ line: lineNo,
90
+ reason: `"${event}" is a milestone and needs a non-empty "sessionId" (the activity log is keyed by it)`,
91
+ };
92
+ }
93
+ const envelope = {};
94
+ const payload = {};
95
+ for (const [key, value] of Object.entries(rec)) {
96
+ if (key === 'event' || key === 'ts')
97
+ continue;
98
+ if (ENVELOPE_KEY_SET.has(key))
99
+ envelope[key] = value;
100
+ else
101
+ payload[key] = value;
102
+ }
103
+ return { line: lineNo, event, ts: rec.ts, envelope, payload };
104
+ }
105
+ function str(value) {
106
+ return typeof value === 'string' && value !== '' ? value : undefined;
107
+ }
108
+ /**
109
+ * Ingest a JSONL batch.
110
+ *
111
+ * Rejection is PER LINE and lossless: a single bad line never discards the rest
112
+ * of the batch. One typo in a 100-event flush must not silently drop 99 real
113
+ * events, and the caller still learns exactly which line failed and why.
114
+ */
115
+ export function ingestBatch(input, opts) {
116
+ const source = opts.source.trim();
117
+ if (!source)
118
+ throw new Error('events emit: --source is required (it names the producer)');
119
+ const result = { written: 0, rejected: [], routed: { operational: 0, activity: 0 } };
120
+ const rawLines = input.split('\n');
121
+ let lineNo = 0;
122
+ for (const raw of rawLines) {
123
+ lineNo += 1;
124
+ if (raw.trim() === '')
125
+ continue;
126
+ const parsed = parseLine(raw, lineNo);
127
+ if ('reason' in parsed) {
128
+ result.rejected.push(parsed);
129
+ continue;
130
+ }
131
+ const route = routeFor(parsed.event, parsed.envelope.sessionId);
132
+ result.routed[route] += 1;
133
+ if (opts.dryRun) {
134
+ result.written += 1;
135
+ continue;
136
+ }
137
+ if (route === 'activity') {
138
+ const sessionId = parsed.envelope.sessionId;
139
+ const ev = {
140
+ ts: parsed.ts ?? new Date().toISOString(),
141
+ event: parsed.event,
142
+ sessionId,
143
+ mailboxId: str(parsed.envelope.mailboxId) ?? sessionId,
144
+ host: str(parsed.envelope.host) ?? '',
145
+ runtime: str(parsed.envelope.runtime) ?? source,
146
+ ...(str(parsed.envelope.cwd) ? { cwd: parsed.envelope.cwd } : {}),
147
+ ...(str(parsed.envelope.project) ? { project: parsed.envelope.project } : {}),
148
+ ...(str(parsed.envelope.agent) ? { agent: parsed.envelope.agent } : {}),
149
+ ...(str(parsed.envelope.tool) ? { tool: parsed.envelope.tool } : {}),
150
+ ...(str(parsed.envelope.detail) ? { detail: parsed.envelope.detail } : {}),
151
+ ...(str(parsed.envelope.url) ? { url: parsed.envelope.url } : {}),
152
+ ...(str(parsed.envelope.launchId) ? { launchId: parsed.envelope.launchId } : {}),
153
+ ...(str(parsed.envelope.terminalId) ? { terminalId: parsed.envelope.terminalId } : {}),
154
+ ...(str(parsed.envelope.tmuxPane) ? { tmuxPane: parsed.envelope.tmuxPane } : {}),
155
+ };
156
+ appendActivityEvent(ev, opts.activityRoot);
157
+ result.written += 1;
158
+ continue;
159
+ }
160
+ // Operational. `module: source` is what makes `--module factory` work;
161
+ // the envelope fields that have no EventPayload home ride along as payload
162
+ // keys, where sanitizePayload still applies its redaction rules.
163
+ const payload = {
164
+ module: source,
165
+ ...parsed.payload,
166
+ ...(str(parsed.envelope.sessionId) ? { sessionId: parsed.envelope.sessionId } : {}),
167
+ ...(str(parsed.envelope.agent) ? { agent: parsed.envelope.agent } : {}),
168
+ ...(str(parsed.envelope.cwd) ? { cwd: parsed.envelope.cwd } : {}),
169
+ ...(str(parsed.envelope.project) ? { project: parsed.envelope.project } : {}),
170
+ ...(str(parsed.envelope.detail) ? { detail: parsed.envelope.detail } : {}),
171
+ ...(str(parsed.envelope.url) ? { url: parsed.envelope.url } : {}),
172
+ ...(str(parsed.envelope.terminalId) ? { terminalId: parsed.envelope.terminalId } : {}),
173
+ ...(str(parsed.envelope.launchId) ? { launchId: parsed.envelope.launchId } : {}),
174
+ ...(str(parsed.envelope.host) ? { sourceHost: parsed.envelope.host } : {}),
175
+ ...(str(parsed.envelope.runtime) ? { runtime: parsed.envelope.runtime } : {}),
176
+ ...(str(parsed.envelope.tool) ? { tool: parsed.envelope.tool } : {}),
177
+ };
178
+ emit(parsed.event, payload, parsed.ts ? { ts: parsed.ts } : {});
179
+ result.written += 1;
180
+ }
181
+ return result;
182
+ }
@@ -13,7 +13,11 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | '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' | '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' | 'friction' | '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.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
+ /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
+ export declare const EVENT_TYPES: readonly EventType[];
19
+ /** Runtime guard for an event kind arriving from outside this process. */
20
+ export declare function isEventType(value: string): value is EventType;
17
21
  export declare function levelFor(event: EventType): EventLevel;
18
22
  export interface EventMeta {
19
23
  ts: string;
@@ -100,8 +104,16 @@ export declare function detectCaller(env?: NodeJS.ProcessEnv, stdoutIsTTY?: bool
100
104
  *
101
105
  * @param event - The event type
102
106
  * @param payload - Event-specific data (agent, version, cwd, etc.)
103
- */
104
- export declare function emit(event: EventType, payload?: EventPayload): void;
107
+ * @param overrides - Envelope fields the CALLER owns rather than the writer.
108
+ * Only `ts` today: a batched out-of-process producer (`agents events emit`)
109
+ * records when each event HAPPENED, but flushes them together later, so
110
+ * stamping write-time would collapse a whole batch onto the flush instant and
111
+ * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
112
+ * *payload* still cannot inject it — this explicit channel is the only way in.
113
+ */
114
+ export declare function emit(event: EventType, payload?: EventPayload, overrides?: {
115
+ ts?: string;
116
+ }): void;
105
117
  /**
106
118
  * Convenience wrapper for timed operations.
107
119
  * Returns a function to call when the operation completes.
@@ -72,9 +72,50 @@ function isDisabled() {
72
72
  const DIR_MODE = 0o700;
73
73
  /** File permissions (owner read/write only). */
74
74
  const FILE_MODE = 0o600;
75
+ /**
76
+ * Every {@link EventType}, as a runtime-checkable table.
77
+ *
78
+ * Typed `Record<EventType, true>` on purpose: the object literal is
79
+ * exhaustiveness-checked at COMPILE time, so adding a member to the union
80
+ * without adding it here fails `tsc`. That is what keeps the runtime validator
81
+ * (`isEventType`, used by `agents events emit` to reject an unknown kind from an
82
+ * out-of-process producer) from silently drifting behind the union.
83
+ */
84
+ const EVENT_TYPE_TABLE = {
85
+ 'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
86
+ 'version.install': true, 'version.switch': true, 'version.remove': true,
87
+ 'skill.install': true, 'skill.remove': true,
88
+ 'browser.launch': true, 'browser.close': true, 'browser.navigate': true, 'browser.screenshot': true,
89
+ 'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
90
+ 'cloud.dispatch': true, 'cloud.complete': true, 'cloud.cancel': true, 'cloud.message': true,
91
+ 'teams.create': true, 'teams.add': true, 'teams.start': true, 'teams.complete': true, 'teams.disband': true,
92
+ 'hook.fire': true, 'hook.complete': true, 'hook.error': true,
93
+ 'mcp.add': true, 'mcp.remove': true, 'mcp.register': true,
94
+ 'resource.sync': true,
95
+ 'rotation.resolved': true,
96
+ 'command.start': true, 'command.end': true,
97
+ 'perf.timing': true,
98
+ 'session.start': true, 'session.end': true,
99
+ 'webhook.received': true, 'webhook.authorized': true, 'webhook.rejected': true, 'webhook.matched': true,
100
+ 'webhook.fired': true, 'webhook.handler.start': true, 'webhook.handler.end': true,
101
+ 'plan.created': true, 'pr.opened': true, 'pr.merged': true, 'worktree.created': true,
102
+ 'worktree.removed': true, 'commit.created': true, 'pushed': true, 'subagent.spawned': true,
103
+ 'artifact.created': true, 'task.completed': true, 'checklist.created': true, 'status.posted': true,
104
+ 'file.edited': true,
105
+ 'factory.command': true, 'factory.action': true, 'factory.uri': true, 'factory.launch': true,
106
+ 'friction': true, 'error': true, 'warn': true, 'info': true, 'debug': true,
107
+ };
108
+ /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
109
+ export const EVENT_TYPES = Object.keys(EVENT_TYPE_TABLE);
110
+ const EVENT_TYPE_SET = new Set(EVENT_TYPES);
111
+ /** Runtime guard for an event kind arriving from outside this process. */
112
+ export function isEventType(value) {
113
+ return EVENT_TYPE_SET.has(value);
114
+ }
75
115
  const AUDIT_EVENTS = new Set([
76
116
  'command.start', 'command.end',
77
- 'secrets.get', 'secrets.unlocked', 'secrets.set', 'secrets.delete', 'secrets.rename',
117
+ 'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view',
118
+ 'secrets.set', 'secrets.delete', 'secrets.rename',
78
119
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
79
120
  'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
80
121
  'version.install', 'version.switch', 'version.remove',
@@ -82,6 +123,11 @@ const AUDIT_EVENTS = new Set([
82
123
  'mcp.add', 'mcp.remove', 'mcp.register',
83
124
  'rotation.resolved',
84
125
  'session.start', 'session.end',
126
+ // An external process reaching into the user's editor (the CLI's
127
+ // vscodium-agent backend driving `/spawn` / `/inject` / `/focus`) is a
128
+ // "who reached in from outside" fact, which is what the audit lane answers.
129
+ // The other factory.* kinds are ordinary info — a palette press is not audit.
130
+ 'factory.uri',
85
131
  ]);
86
132
  export function levelFor(event) {
87
133
  if (event === 'warn')
@@ -334,8 +380,14 @@ function resolveProvenance(env = process.env) {
334
380
  *
335
381
  * @param event - The event type
336
382
  * @param payload - Event-specific data (agent, version, cwd, etc.)
383
+ * @param overrides - Envelope fields the CALLER owns rather than the writer.
384
+ * Only `ts` today: a batched out-of-process producer (`agents events emit`)
385
+ * records when each event HAPPENED, but flushes them together later, so
386
+ * stamping write-time would collapse a whole batch onto the flush instant and
387
+ * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
388
+ * *payload* still cannot inject it — this explicit channel is the only way in.
337
389
  */
338
- export function emit(event, payload = {}) {
390
+ export function emit(event, payload = {}, overrides = {}) {
339
391
  if (isDisabled())
340
392
  return;
341
393
  try {
@@ -346,7 +398,7 @@ export function emit(event, payload = {}) {
346
398
  // Provenance floor first: env-sourced defaults an explicit payload overrides.
347
399
  ...resolveProvenance(),
348
400
  ...safePayload,
349
- ts: new Date().toISOString(),
401
+ ts: overrides.ts ?? new Date().toISOString(),
350
402
  tz: getTimezoneOffset(),
351
403
  tzName: getTimezoneName(),
352
404
  hostname: os.hostname(),
@@ -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, projectFromCwd, } from './activity.js';
18
+ import { appendActivityEvent, } from './activity.js';
19
+ import { resolveProjectNameForCwd, listProjectDefs } from './projects.js';
19
20
  import { getHistoryDir } from './state.js';
20
21
  import { machineId } from './machine-id.js';
21
22
  import { isValidMailboxId } from './mailbox.js';
@@ -263,7 +264,12 @@ export function postFeedStatus(input) {
263
264
  + '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
264
265
  }
265
266
  const ts = input.ts ?? new Date().toISOString();
266
- const project = projectFromCwd(identity.cwd);
267
+ // The post is written where the agent runs, so the cwd is a local path and
268
+ // gets full canonical resolution — a defined project's name wins (a post from
269
+ // any repo of a multi-repo project files under that project), else the repo
270
+ // key, matching how the timeline groups everything else. listProjectDefs is
271
+ // fail-open, so this costs one small readdir + YAML parse per post.
272
+ const project = resolveProjectNameForCwd(identity.cwd, listProjectDefs());
267
273
  const attachments = buildAttachments(input.attach, {
268
274
  copyRoot: input.attachmentsRoot ?? path.join(getHistoryDir(), 'attachments'),
269
275
  sessionId: identity.sessionId,
@@ -88,6 +88,7 @@ export const RUN_OPTION_FORWARDING = {
88
88
  autoSecrets: 'forward', // workflow frontmatter secrets resolve on the REMOTE keychain
89
89
  emitSessionId: 'forward', // remote prints its session id as a stdout sentinel the launcher captures (session-marker.ts)
90
90
  // rejected — cannot cross the SSH boundary; fail loud, never degrade
91
+ terminal: 'reject', // opens a tab on THIS machine's desktop; a remote tab is a different request
91
92
  secrets: 'reject',
92
93
  secretsKeys: 'reject',
93
94
  allowExpired: 'reject',
@@ -126,6 +127,9 @@ export const RUN_OPTION_FORWARDING = {
126
127
  };
127
128
  /** Actionable messages for value-aware rejections, keyed by attribute name. */
128
129
  export const RUN_OPTION_REJECT_MESSAGES = {
130
+ terminal: '--terminal opens a tab on THIS machine; it cannot be combined with --host. ' +
131
+ 'Drop --terminal to dispatch to the host, or drop --host to open the tab here. ' +
132
+ 'To watch a remote run in a terminal, dispatch it and follow with `agents sessions focus <id>`.',
129
133
  secrets: '--secrets cannot cross the SSH boundary — Keychain values are never sent to a host implicitly. ' +
130
134
  'Provision the bundle on the host first (agents secrets export --host <name>), then run without --secrets; ' +
131
135
  'workflow frontmatter secrets resolve from the HOST\'s own keychain.',