@phnx-labs/agents-cli 1.20.92 → 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.
- package/CHANGELOG.md +121 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.js +91 -1
- package/dist/commands/projects.d.ts +10 -0
- package/dist/commands/projects.js +189 -8
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +16 -0
- package/dist/lib/activity.d.ts +8 -0
- package/dist/lib/activity.js +7 -0
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/state.d.ts +12 -0
- package/dist/lib/state.js +14 -0
- package/dist/lib/types.d.ts +5 -4
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -147,11 +147,25 @@ function auditCommandPath(cmd) {
|
|
|
147
147
|
return parts;
|
|
148
148
|
}
|
|
149
149
|
const auditStarts = new WeakMap();
|
|
150
|
+
/**
|
|
151
|
+
* Commands that WRITE the event stream, so recording their own invocation would
|
|
152
|
+
* add records to the log they are writing into. `events emit` is batched — one
|
|
153
|
+
* flush every few seconds per open editor window — so auditing it would bury the
|
|
154
|
+
* real events under two `command.*` records per flush. `_internal friction`
|
|
155
|
+
* exists for the same reason (shell guards fire before any `agents` process
|
|
156
|
+
* exists, so they cannot emit in-process) and had the same self-logging bug.
|
|
157
|
+
*/
|
|
158
|
+
const AUDIT_EXEMPT_COMMANDS = new Set([
|
|
159
|
+
'events emit',
|
|
160
|
+
'_internal friction',
|
|
161
|
+
]);
|
|
150
162
|
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
151
163
|
try {
|
|
152
164
|
const parts = auditCommandPath(actionCommand);
|
|
153
165
|
if (parts.length === 0)
|
|
154
166
|
return;
|
|
167
|
+
if (AUDIT_EXEMPT_COMMANDS.has(parts.join(' ')))
|
|
168
|
+
return;
|
|
155
169
|
auditStarts.set(actionCommand, Date.now());
|
|
156
170
|
emit('command.start', {
|
|
157
171
|
module: parts[0],
|
|
@@ -172,6 +186,8 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
|
|
|
172
186
|
const parts = auditCommandPath(actionCommand);
|
|
173
187
|
if (parts.length === 0)
|
|
174
188
|
return;
|
|
189
|
+
if (AUDIT_EXEMPT_COMMANDS.has(parts.join(' ')))
|
|
190
|
+
return;
|
|
175
191
|
const started = auditStarts.get(actionCommand);
|
|
176
192
|
const durationMs = started !== undefined ? Date.now() - started : undefined;
|
|
177
193
|
const command = parts.join(' ');
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'workt
|
|
|
5
5
|
| 'video.rendered' | 'video.converted' | 'image.upscaled' | 'metadata.edited'
|
|
6
6
|
/** Deliberate agent-authored progress post (`agents feed post`). */
|
|
7
7
|
| 'status.posted'
|
|
8
|
+
/**
|
|
9
|
+
* An agent terminal spawned from the Factory VS Code extension. A milestone
|
|
10
|
+
* because it is the BIRTH of a session — it carries the sessionId and the
|
|
11
|
+
* terminalId that every later event on that session joins through, the same
|
|
12
|
+
* way `subagent.spawned` roots a sub-agent. Written out of process by
|
|
13
|
+
* `agents events emit`; the other factory.* kinds are operational-only.
|
|
14
|
+
*/
|
|
15
|
+
| 'factory.launch'
|
|
8
16
|
/**
|
|
9
17
|
* The same post, but the agent is STUCK (`agents feed post --blocked`).
|
|
10
18
|
*
|
package/dist/lib/activity.js
CHANGED
|
@@ -43,6 +43,12 @@ export const MILESTONE_EVENTS = [
|
|
|
43
43
|
'image.upscaled',
|
|
44
44
|
'metadata.edited',
|
|
45
45
|
'status.posted',
|
|
46
|
+
// NOTE: intentionally absent from the Python hook's own MILESTONE_EVENTS copy
|
|
47
|
+
// (see ACTIVITY_LOG_HOOK_SCRIPT below) — that set only classifies events the
|
|
48
|
+
// hook itself writes from PreToolUse/PostToolUse, and the hook never writes
|
|
49
|
+
// this one. activity.test.ts pins the difference so the divergence stays
|
|
50
|
+
// deliberate rather than becoming drift.
|
|
51
|
+
'factory.launch',
|
|
46
52
|
'status.blocked',
|
|
47
53
|
];
|
|
48
54
|
const MILESTONE_SET = new Set(MILESTONE_EVENTS);
|
|
@@ -302,6 +308,7 @@ export const EVENT_STYLE = {
|
|
|
302
308
|
'checklist.created': { glyph: '☐', color: chalk.cyan, label: 'checklist created' },
|
|
303
309
|
'status.posted': { glyph: '▸', color: chalk.white, label: 'status' },
|
|
304
310
|
'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
|
|
311
|
+
'factory.launch': { glyph: '⌁', color: chalk.cyan, label: 'factory launch' },
|
|
305
312
|
'bash.executed': { glyph: '$', color: chalk.gray, label: 'command run' },
|
|
306
313
|
};
|
|
307
314
|
export function styleForEvent(event) {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/lib/events.js
CHANGED
|
@@ -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.
|
|
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(),
|