@phnx-labs/agents-cli 1.20.88 → 1.20.89

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 (55) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/factory.js +26 -2
  6. package/dist/commands/funnel.js +16 -1
  7. package/dist/commands/menubar.js +117 -34
  8. package/dist/commands/routines.js +23 -1
  9. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  10. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  11. package/dist/commands/secrets.js +2 -0
  12. package/dist/commands/sessions.d.ts +7 -1
  13. package/dist/commands/sessions.js +39 -12
  14. package/dist/commands/webhook.js +7 -2
  15. package/dist/lib/commands.js +9 -1
  16. package/dist/lib/daemon.d.ts +29 -0
  17. package/dist/lib/daemon.js +58 -4
  18. package/dist/lib/events.d.ts +1 -1
  19. package/dist/lib/factory/snapshot.d.ts +78 -0
  20. package/dist/lib/factory/snapshot.js +209 -0
  21. package/dist/lib/fs-atomic.d.ts +14 -1
  22. package/dist/lib/fs-atomic.js +35 -3
  23. package/dist/lib/funnel.d.ts +1 -0
  24. package/dist/lib/funnel.js +8 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  28. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  29. package/dist/lib/menubar/install-menubar.js +183 -28
  30. package/dist/lib/platform/process.d.ts +2 -0
  31. package/dist/lib/platform/process.js +5 -3
  32. package/dist/lib/resources.d.ts +8 -0
  33. package/dist/lib/resources.js +34 -1
  34. package/dist/lib/routines-placement.d.ts +2 -1
  35. package/dist/lib/routines-placement.js +8 -4
  36. package/dist/lib/routines.d.ts +57 -1
  37. package/dist/lib/routines.js +74 -1
  38. package/dist/lib/runner.d.ts +2 -0
  39. package/dist/lib/runner.js +21 -8
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/bundles.js +9 -34
  43. package/dist/lib/secrets/filestore.d.ts +152 -34
  44. package/dist/lib/secrets/filestore.js +676 -123
  45. package/dist/lib/session/remote-active.d.ts +4 -1
  46. package/dist/lib/session/remote-active.js +8 -2
  47. package/dist/lib/session/viewing-in.d.ts +31 -0
  48. package/dist/lib/session/viewing-in.js +47 -0
  49. package/dist/lib/state.d.ts +17 -0
  50. package/dist/lib/state.js +30 -2
  51. package/dist/lib/triggers/handlers.d.ts +95 -0
  52. package/dist/lib/triggers/handlers.js +384 -0
  53. package/dist/lib/triggers/webhook.d.ts +10 -2
  54. package/dist/lib/triggers/webhook.js +65 -11
  55. package/package.json +1 -1
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Webhook handler config layer.
3
+ *
4
+ * Handlers are one-off triggers stored in `~/.agents/webhooks/*.yml` (plus
5
+ * project/system layers). They complement routine triggers: a matching webhook
6
+ * can run an agent, workflow, shell command, or delegate to a routine.
7
+ */
8
+ import { exec } from 'child_process';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as yaml from 'yaml';
12
+ import { emit } from '../events.js';
13
+ import { machineId, normalizeHost } from '../machine-id.js';
14
+ import { safeJoin } from '../paths.js';
15
+ import { pickFleetDevice } from '../routines-placement.js';
16
+ import { assertShellSubstitutionSupported, readJob, substituteWebhookCommand, substituteWebhookPrompt, } from '../routines.js';
17
+ import { ensureAgentsDir, getProjectWebhooksDir, getSystemWebhooksDir, getWebhooksDir } from '../state.js';
18
+ const HANDLER_DEFAULTS = {
19
+ enabled: true,
20
+ };
21
+ /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
22
+ function payloadRepo(payload) {
23
+ const repo = payload?.repository;
24
+ const fullName = repo?.full_name;
25
+ return typeof fullName === 'string' && fullName.length > 0 ? fullName : null;
26
+ }
27
+ /** Strip a `refs/heads/` (or `refs/tags/`) prefix to the short branch/tag name. */
28
+ function shortRef(ref) {
29
+ return ref.replace(/^refs\/(heads|tags)\//, '');
30
+ }
31
+ /** Extract candidate branches a webhook payload references. */
32
+ function payloadBranches(event, payload) {
33
+ const branches = new Set();
34
+ const add = (v) => {
35
+ if (typeof v === 'string' && v.length > 0)
36
+ branches.add(shortRef(v));
37
+ };
38
+ switch (event) {
39
+ case 'push':
40
+ add(payload.ref);
41
+ break;
42
+ case 'pull_request': {
43
+ const pr = payload.pull_request;
44
+ add(pr?.base?.ref);
45
+ add(pr?.head?.ref);
46
+ break;
47
+ }
48
+ case 'workflow_run': {
49
+ const run = payload.workflow_run;
50
+ add(run?.head_branch);
51
+ break;
52
+ }
53
+ default:
54
+ break;
55
+ }
56
+ return [...branches];
57
+ }
58
+ function linearAction(payload) {
59
+ return typeof payload.action === 'string' ? payload.action : null;
60
+ }
61
+ function linearTeamKey(payload) {
62
+ const data = payload.data;
63
+ const identifier = data?.identifier;
64
+ if (typeof identifier === 'string') {
65
+ const match = /^([A-Z][A-Z0-9]*)-\d+$/.exec(identifier);
66
+ if (match)
67
+ return match[1];
68
+ }
69
+ const team = data?.team;
70
+ return typeof team?.key === 'string' ? team.key : null;
71
+ }
72
+ function linearLabels(payload) {
73
+ const data = payload.data;
74
+ const labels = Array.isArray(data?.labels) ? data?.labels : [];
75
+ return labels
76
+ .map((n) => n.name)
77
+ .filter((n) => typeof n === 'string' && n.length > 0);
78
+ }
79
+ function githubAction(payload) {
80
+ return typeof payload.action === 'string' ? payload.action : null;
81
+ }
82
+ function githubLabels(payload) {
83
+ const names = new Set();
84
+ const add = (value) => {
85
+ if (typeof value === 'string' && value.length > 0)
86
+ names.add(value);
87
+ };
88
+ const deliveryLabel = payload.label;
89
+ add(deliveryLabel?.name);
90
+ const pr = payload.pull_request;
91
+ const prLabels = Array.isArray(pr?.labels) ? pr.labels : [];
92
+ for (const label of prLabels) {
93
+ add(label.name);
94
+ }
95
+ const issue = payload.issue;
96
+ const issueLabels = Array.isArray(issue?.labels) ? issue.labels : [];
97
+ for (const label of issueLabels) {
98
+ add(label.name);
99
+ }
100
+ return [...names];
101
+ }
102
+ function readHandlerFile(filePath) {
103
+ try {
104
+ const content = fs.readFileSync(filePath, 'utf-8');
105
+ const parsed = yaml.parse(content);
106
+ if (!parsed || typeof parsed !== 'object')
107
+ return null;
108
+ return {
109
+ ...HANDLER_DEFAULTS,
110
+ ...parsed,
111
+ name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
112
+ };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function handlerRunsOnThisDevice(handler) {
119
+ if (!handler.devices || handler.devices.length === 0)
120
+ return true;
121
+ const self = machineId();
122
+ return handler.devices.some((d) => normalizeHost(d) === self);
123
+ }
124
+ const HOST_PLATFORMS = ['linux', 'macos', 'windows'];
125
+ function parseHostPlatform(raw) {
126
+ const parts = raw
127
+ .split('/')
128
+ .map((s) => s.trim().toLowerCase())
129
+ .filter((s) => s.length > 0);
130
+ const platformIdx = parts.findIndex((p) => HOST_PLATFORMS.includes(p));
131
+ if (platformIdx === -1)
132
+ return { base: parts.join('/') };
133
+ const platform = parts[platformIdx];
134
+ const rest = parts.filter((_, i) => i !== platformIdx).join('/');
135
+ return { base: rest, platform };
136
+ }
137
+ /**
138
+ * Resolve a handler `host` expression to a concrete host or local execution.
139
+ *
140
+ * - Specific device name (e.g. `yosemite-s0`) → run there over SSH, or locally
141
+ * if it names this machine.
142
+ * - `fleet` → pick any online worker device.
143
+ * - `fleet/<platform>` or `<platform>/fleet` (e.g. `fleet/linux`, `linux/fleet`)
144
+ * → pick any online worker on that platform. `linux` alone is accepted as a
145
+ * shorthand for `fleet/linux`.
146
+ *
147
+ * Throws when a fleet expression matches no eligible device, rather than
148
+ * silently falling back to this machine — `fleet/linux` must never land on a
149
+ * macOS box.
150
+ */
151
+ export function resolveHandlerHost(host) {
152
+ if (!host || host.trim() === '')
153
+ return {};
154
+ const { base, platform } = parseHostPlatform(host);
155
+ const isFleet = base === '' || base === 'fleet';
156
+ if (isFleet) {
157
+ const picked = pickFleetDevice(undefined, platform);
158
+ if (!picked) {
159
+ throw new Error(`handler host '${host}': no eligible online fleet device`);
160
+ }
161
+ if (normalizeHost(picked) === machineId())
162
+ return {};
163
+ return { host: picked, hostStrategy: 'host' };
164
+ }
165
+ if (normalizeHost(base) === machineId())
166
+ return {};
167
+ return { host: base, hostStrategy: 'host' };
168
+ }
169
+ /**
170
+ * List all webhook handlers, scanning project > user > system webhook dirs.
171
+ * Higher layers shadow lower ones of the same name (first-seen wins).
172
+ */
173
+ export function listHandlers(cwd) {
174
+ ensureAgentsDir();
175
+ const seen = new Set();
176
+ const handlers = [];
177
+ const dirs = [];
178
+ if (cwd) {
179
+ const projectDir = getProjectWebhooksDir(cwd);
180
+ if (projectDir)
181
+ dirs.push({ scope: 'project', path: projectDir });
182
+ }
183
+ dirs.push({ scope: 'user', path: getWebhooksDir() });
184
+ dirs.push({ scope: 'system', path: getSystemWebhooksDir() });
185
+ for (const { path: dir } of dirs) {
186
+ if (!fs.existsSync(dir))
187
+ continue;
188
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
189
+ for (const file of files) {
190
+ const handler = readHandlerFile(safeJoin(dir, file));
191
+ if (!handler)
192
+ continue;
193
+ if (seen.has(handler.name))
194
+ continue;
195
+ seen.add(handler.name);
196
+ handlers.push(handler);
197
+ }
198
+ }
199
+ return handlers;
200
+ }
201
+ /** Pure matcher: does this handler match the incoming webhook? */
202
+ export function handlerMatchesWebhook(handler, webhook) {
203
+ if (handler.enabled === false)
204
+ return false;
205
+ if (handler.source !== webhook.source)
206
+ return false;
207
+ if (handler.event && handler.event !== webhook.event)
208
+ return false;
209
+ if (!handlerRunsOnThisDevice(handler))
210
+ return false;
211
+ if (handler.action) {
212
+ const action = webhook.source === 'github' ? githubAction(webhook.payload) : linearAction(webhook.payload);
213
+ if (action !== handler.action)
214
+ return false;
215
+ }
216
+ if (webhook.source === 'linear') {
217
+ if (handler.teamKey && linearTeamKey(webhook.payload) !== handler.teamKey)
218
+ return false;
219
+ if (handler.label) {
220
+ const expected = handler.label.toLowerCase();
221
+ if (!linearLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
222
+ return false;
223
+ }
224
+ if (handler.stateTo) {
225
+ const data = webhook.payload.data;
226
+ const current = data?.state?.name;
227
+ if (current !== handler.stateTo)
228
+ return false;
229
+ }
230
+ if (handler.stateFrom) {
231
+ const updatedFrom = webhook.payload.updatedFrom;
232
+ const previous = updatedFrom?.state?.name;
233
+ if (previous !== handler.stateFrom)
234
+ return false;
235
+ }
236
+ return true;
237
+ }
238
+ if (handler.repo) {
239
+ const repo = payloadRepo(webhook.payload);
240
+ if (!repo || repo.toLowerCase() !== handler.repo.toLowerCase())
241
+ return false;
242
+ }
243
+ if (handler.branch) {
244
+ const branches = payloadBranches(webhook.event, webhook.payload);
245
+ if (!branches.some((b) => b === handler.branch))
246
+ return false;
247
+ }
248
+ if (handler.label) {
249
+ const expected = handler.label.toLowerCase();
250
+ if (!githubLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
251
+ return false;
252
+ }
253
+ return true;
254
+ }
255
+ /**
256
+ * Build the variable-substitution context for a webhook. Linear events expose
257
+ * `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
258
+ * and `issue`.
259
+ */
260
+ export function buildWebhookContext(webhook) {
261
+ const action = webhook.source === 'github'
262
+ ? githubAction(webhook.payload) ?? undefined
263
+ : linearAction(webhook.payload) ?? undefined;
264
+ if (webhook.source === 'linear') {
265
+ return {
266
+ source: webhook.source,
267
+ event: webhook.event,
268
+ action,
269
+ issue: webhook.payload.data,
270
+ updatedFrom: webhook.payload.updatedFrom,
271
+ };
272
+ }
273
+ return {
274
+ source: webhook.source,
275
+ event: webhook.event,
276
+ action,
277
+ repository: webhook.payload.repository,
278
+ pull_request: webhook.payload.pull_request,
279
+ issue: webhook.payload.issue,
280
+ };
281
+ }
282
+ function defaultExecCommand(command) {
283
+ return new Promise((resolve) => {
284
+ exec(command, (error, stdout, stderr) => {
285
+ const output = stdout + stderr;
286
+ if (error) {
287
+ resolve({ exitCode: typeof error.code === 'number' ? error.code : 1, output });
288
+ }
289
+ else {
290
+ resolve({ exitCode: 0, output });
291
+ }
292
+ });
293
+ });
294
+ }
295
+ function dispatchDefault(config) {
296
+ // Import lazily so the runner module (heavy) is only loaded when a handler
297
+ // actually needs to spawn. Tests inject dispatch functions, so this path is
298
+ // not exercised in unit tests.
299
+ return import('../runner.js').then((m) => m.executeJobDetached(config));
300
+ }
301
+ /**
302
+ * Execute a handler's action. Runs the configured agent/workflow/command or
303
+ * delegates to a routine, substituting `{{...}}` placeholders from the webhook
304
+ * context.
305
+ */
306
+ export async function executeHandler(handler, webhook, opts = {}) {
307
+ const context = buildWebhookContext(webhook);
308
+ const base = { handlerName: handler.name };
309
+ emit('webhook.handler.start', {
310
+ source: webhook.source,
311
+ event: webhook.event,
312
+ handlerName: handler.name,
313
+ });
314
+ try {
315
+ const result = await executeHandlerAction(handler, webhook, context, opts);
316
+ emit('webhook.handler.end', {
317
+ source: webhook.source,
318
+ event: webhook.event,
319
+ handlerName: handler.name,
320
+ status: 'success',
321
+ ...result,
322
+ });
323
+ return { ...base, ...result };
324
+ }
325
+ catch (err) {
326
+ const error = err.message;
327
+ emit('webhook.handler.end', {
328
+ source: webhook.source,
329
+ event: webhook.event,
330
+ handlerName: handler.name,
331
+ status: 'error',
332
+ error,
333
+ });
334
+ throw err;
335
+ }
336
+ }
337
+ async function executeHandlerAction(handler, webhook, context, opts) {
338
+ const substitutedPrompt = handler.run?.prompt ? substituteWebhookPrompt(handler.run.prompt, context) : '';
339
+ // Resolved once so a fleet pick can't differ between the two dispatch paths.
340
+ const hostFields = resolveHandlerHost(handler.host);
341
+ if (handler.run?.agent || handler.run?.workflow) {
342
+ const config = {
343
+ name: handler.name,
344
+ mode: 'auto',
345
+ effort: 'auto',
346
+ timeout: '10m',
347
+ enabled: true,
348
+ prompt: substitutedPrompt,
349
+ ...(handler.run.agent ? { agent: handler.run.agent } : { workflow: handler.run.workflow }),
350
+ ...(handler.devices ? { devices: handler.devices } : {}),
351
+ ...(handler.run.env ? { env: handler.run.env } : {}),
352
+ ...(hostFields.host ? { host: hostFields.host } : {}),
353
+ ...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
354
+ };
355
+ const dispatch = handler.run.agent
356
+ ? (opts.dispatchAgent ?? dispatchDefault)
357
+ : (opts.dispatchWorkflow ?? dispatchDefault);
358
+ const meta = await dispatch(config);
359
+ return { runId: meta.runId };
360
+ }
361
+ if (handler.run?.command) {
362
+ assertShellSubstitutionSupported(handler.run.command);
363
+ const command = substituteWebhookCommand(handler.run.command, context);
364
+ const exec = opts.execCommand ?? defaultExecCommand;
365
+ const { exitCode, output } = await exec(command);
366
+ return { exitCode, output };
367
+ }
368
+ if (handler.routine) {
369
+ const routine = readJob(handler.routine);
370
+ if (!routine)
371
+ throw new Error(`routine '${handler.routine}' not found`);
372
+ const config = {
373
+ ...routine,
374
+ prompt: substituteWebhookPrompt(routine.prompt, context),
375
+ ...(handler.devices ? { devices: handler.devices } : {}),
376
+ ...(hostFields.host ? { host: hostFields.host } : {}),
377
+ ...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
378
+ };
379
+ const dispatch = opts.dispatchRoutine ?? dispatchDefault;
380
+ const meta = await dispatch(config);
381
+ return { runId: meta.runId };
382
+ }
383
+ throw new Error(`handler '${handler.name}' has no run or routine action`);
384
+ }
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import * as http from 'http';
15
15
  import type { IncomingHttpHeaders } from 'http';
16
- import type { JobConfig, RunMeta } from '../routines.js';
16
+ import type { JobConfig, RunMeta, WebhookContext } from '../routines.js';
17
+ import { type FiredHandler } from './handlers.js';
17
18
  export type WebhookSource = 'github' | 'linear';
18
19
  export interface IncomingWebhook {
19
20
  /** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
@@ -41,6 +42,11 @@ export declare function webhookRepo(payload: Record<string, unknown>): string |
41
42
  * - issue_comment: no branch (comments aren't branch-scoped)
42
43
  */
43
44
  export declare function webhookBranches(event: string, payload: Record<string, unknown>): string[];
45
+ export declare function linearAction(payload: Record<string, unknown>): string | null;
46
+ export declare function linearTeamKey(payload: Record<string, unknown>): string | null;
47
+ export declare function linearLabels(payload: Record<string, unknown>): string[];
48
+ export declare function githubAction(payload: Record<string, unknown>): string | null;
49
+ export declare function githubLabels(payload: Record<string, unknown>): string[];
44
50
  /** True when a single job's trigger matches the given webhook. Pure. */
45
51
  export declare function jobMatchesWebhook(job: JobConfig, webhook: IncomingWebhook): boolean;
46
52
  /**
@@ -63,6 +69,8 @@ export interface FireWebhookOptions {
63
69
  skipJobNames?: ReadonlySet<string>;
64
70
  /** Called immediately after a single matched job dispatch succeeds. */
65
71
  onJobFired?: (job: JobConfig, fired: FiredJob) => void;
72
+ /** Optional webhook context used to expand `{{...}}` placeholders in prompts. */
73
+ context?: WebhookContext;
66
74
  }
67
75
  /** Result of firing one matched job. */
68
76
  export interface FiredJob {
@@ -124,7 +132,7 @@ export interface WebhookServerOptions {
124
132
  /** Override the fire options (mainly for tests). */
125
133
  fire?: FireWebhookOptions;
126
134
  /** Called after each delivery is handled (mainly for tests/observability). */
127
- onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[]) => void;
135
+ onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[], handlers: FiredHandler[]) => void;
128
136
  deliveryStore?: DeliveryStore;
129
137
  rateLimiter?: RateLimiter;
130
138
  rateLimitPerMinute?: number;
@@ -15,8 +15,10 @@ import * as crypto from 'crypto';
15
15
  import * as fs from 'fs';
16
16
  import * as http from 'http';
17
17
  import * as path from 'path';
18
- import { jobRunsOnThisDevice, listJobs } from '../routines.js';
18
+ import { jobRunsOnThisDevice, listJobs, substituteWebhookPrompt } from '../routines.js';
19
19
  import { executeJobDetached } from '../runner.js';
20
+ import { emit } from '../events.js';
21
+ import { listHandlers, handlerMatchesWebhook, executeHandler, buildWebhookContext, } from './handlers.js';
20
22
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
21
23
  export function webhookRepo(payload) {
22
24
  const repo = payload?.repository;
@@ -62,10 +64,10 @@ export function webhookBranches(event, payload) {
62
64
  }
63
65
  return [...branches];
64
66
  }
65
- function linearAction(payload) {
67
+ export function linearAction(payload) {
66
68
  return typeof payload.action === 'string' ? payload.action : null;
67
69
  }
68
- function linearTeamKey(payload) {
70
+ export function linearTeamKey(payload) {
69
71
  const data = payload.data;
70
72
  const identifier = data?.identifier;
71
73
  if (typeof identifier === 'string') {
@@ -76,7 +78,7 @@ function linearTeamKey(payload) {
76
78
  const team = data?.team;
77
79
  return typeof team?.key === 'string' ? team.key : null;
78
80
  }
79
- function linearLabels(payload) {
81
+ export function linearLabels(payload) {
80
82
  // Linear webhook bodies flatten list relations: an Issue event carries
81
83
  // `data.labels` as a flat array of label objects (`[{ id, name, color }]`),
82
84
  // NOT the `{ nodes: [...] }` connection shape returned by the GraphQL API.
@@ -87,10 +89,10 @@ function linearLabels(payload) {
87
89
  .map((n) => n.name)
88
90
  .filter((n) => typeof n === 'string' && n.length > 0);
89
91
  }
90
- function githubAction(payload) {
92
+ export function githubAction(payload) {
91
93
  return typeof payload.action === 'string' ? payload.action : null;
92
94
  }
93
- function githubLabels(payload) {
95
+ export function githubLabels(payload) {
94
96
  const names = new Set();
95
97
  const add = (value) => {
96
98
  if (typeof value === 'string' && value.length > 0)
@@ -148,6 +150,18 @@ function linearTriggerMatches(trigger, webhook) {
148
150
  if (!linearLabels(webhook.payload).some((name) => name.toLowerCase() === expected))
149
151
  return false;
150
152
  }
153
+ if (trigger.stateTo) {
154
+ const data = webhook.payload.data;
155
+ const current = data?.state?.name;
156
+ if (current !== trigger.stateTo)
157
+ return false;
158
+ }
159
+ if (trigger.stateFrom) {
160
+ const updatedFrom = webhook.payload.updatedFrom;
161
+ const previous = updatedFrom?.state?.name;
162
+ if (previous !== trigger.stateFrom)
163
+ return false;
164
+ }
151
165
  return true;
152
166
  }
153
167
  /** True when a single job's trigger matches the given webhook. Pure. */
@@ -193,8 +207,12 @@ export async function fireWebhookJobs(webhook, options = {}) {
193
207
  for (const job of matched) {
194
208
  if (skipJobNames.has(job.name))
195
209
  continue;
210
+ emit('webhook.matched', { source: webhook.source, event: webhook.event, jobName: job.name });
196
211
  try {
197
- const meta = await dispatch(job);
212
+ const jobToDispatch = options.context
213
+ ? { ...job, prompt: substituteWebhookPrompt(job.prompt, options.context) }
214
+ : job;
215
+ const meta = await dispatch(jobToDispatch);
198
216
  const firedJob = { jobName: job.name, runId: meta.runId };
199
217
  fired.push(firedJob);
200
218
  options.onJobFired?.(job, firedJob);
@@ -419,6 +437,7 @@ export function startWebhookServer(options) {
419
437
  }
420
438
  const secret = options.secrets[source];
421
439
  if (!secret) {
440
+ emit('webhook.rejected', { source, reason: 'missing webhook secret' });
422
441
  res.writeHead(503, { 'content-type': 'application/json' });
423
442
  res.end(JSON.stringify({ ok: false, error: `missing ${source} webhook secret` }));
424
443
  return;
@@ -430,12 +449,14 @@ export function startWebhookServer(options) {
430
449
  // shed this load on its own.
431
450
  const ip = req.socket.remoteAddress ?? 'unknown';
432
451
  if (!ipRateLimiter.take(ip)) {
452
+ emit('webhook.rejected', { source, reason: 'ip rate limit exceeded' });
433
453
  res.writeHead(429, { 'content-type': 'application/json' });
434
454
  res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
435
455
  return;
436
456
  }
437
457
  const declaredLength = Number.parseInt(header(req.headers, 'content-length') ?? '', 10);
438
458
  if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
459
+ emit('webhook.rejected', { source, reason: 'payload too large' });
439
460
  res.writeHead(413, { 'content-type': 'application/json' });
440
461
  res.end(JSON.stringify({ ok: false, error: `payload exceeds ${maxBodyBytes} bytes` }));
441
462
  return;
@@ -446,45 +467,78 @@ export function startWebhookServer(options) {
446
467
  ? verifyGithubSignature(req.headers, rawBody, secret)
447
468
  : verifyLinearSignature(req.headers, rawBody, secret);
448
469
  if (!valid) {
470
+ emit('webhook.rejected', { source, reason: 'invalid signature' });
449
471
  res.writeHead(401, { 'content-type': 'application/json' });
450
472
  res.end(JSON.stringify({ ok: false, error: 'invalid signature' }));
451
473
  return;
452
474
  }
453
475
  const id = deliveryId(source, req.headers, rawBody);
476
+ const event = source === 'github' ? (header(req.headers, 'x-github-event') ?? '') : '';
477
+ emit('webhook.received', { source, event, deliveryId: id });
454
478
  if (deliveryStore.seen(id)) {
455
479
  res.writeHead(200, { 'content-type': 'application/json' });
456
480
  res.end(JSON.stringify({ ok: true, duplicate: true, fired: [] }));
457
481
  return;
458
482
  }
459
483
  const payload = rawBody.length > 0 ? JSON.parse(rawBody.toString('utf-8')) : {};
484
+ const webhookEvent = source === 'github' ? event : String(payload.type ?? '');
460
485
  if (source === 'linear' && !verifyLinearTimestamp(payload)) {
486
+ emit('webhook.rejected', { source, event: webhookEvent, deliveryId: id, reason: 'stale linear webhook timestamp' });
461
487
  res.writeHead(401, { 'content-type': 'application/json' });
462
488
  res.end(JSON.stringify({ ok: false, error: 'stale linear webhook timestamp' }));
463
489
  return;
464
490
  }
465
491
  if (!rateLimiter.take(source)) {
492
+ emit('webhook.rejected', { source, event: webhookEvent, deliveryId: id, reason: 'rate limit exceeded' });
466
493
  res.writeHead(429, { 'content-type': 'application/json' });
467
494
  res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
468
495
  return;
469
496
  }
470
497
  const webhook = {
471
498
  source,
472
- event: source === 'github' ? (header(req.headers, 'x-github-event') ?? '') : String(payload.type ?? ''),
499
+ event: webhookEvent,
473
500
  payload,
474
501
  };
502
+ emit('webhook.authorized', { source, event: webhookEvent, deliveryId: id });
503
+ const context = buildWebhookContext(webhook);
475
504
  const fireOptions = options.fire ?? {};
476
- const fired = await fireWebhookJobs(webhook, {
505
+ const firedJobs = await fireWebhookJobs(webhook, {
477
506
  ...fireOptions,
507
+ context,
478
508
  skipJobNames: deliveryStore.completedJobs(id),
479
509
  onJobFired: (job, firedJob) => {
510
+ emit('webhook.fired', { source, event: webhookEvent, deliveryId: id, jobName: job.name, runId: firedJob.runId });
480
511
  deliveryStore.markJob(id, job.name);
481
512
  fireOptions.onJobFired?.(job, firedJob);
482
513
  },
483
514
  });
515
+ const handlers = listHandlers();
516
+ const matchedHandlers = handlers.filter((handler) => handlerMatchesWebhook(handler, webhook));
517
+ const firedHandlers = [];
518
+ const handlerErrors = [];
519
+ await Promise.allSettled(matchedHandlers.map(async (handler) => {
520
+ if (deliveryStore.completedJobs(id).has(handler.name))
521
+ return;
522
+ emit('webhook.matched', { source, event: webhookEvent, deliveryId: id, handlerName: handler.name });
523
+ try {
524
+ const result = await executeHandler(handler, webhook);
525
+ deliveryStore.markJob(id, handler.name);
526
+ firedHandlers.push(result);
527
+ }
528
+ catch (err) {
529
+ handlerErrors.push({ handlerName: handler.name, error: err.message });
530
+ }
531
+ }));
484
532
  deliveryStore.mark(id);
485
- options.onDelivery?.(webhook, fired);
533
+ options.onDelivery?.(webhook, firedJobs, firedHandlers);
486
534
  res.writeHead(200, { 'content-type': 'application/json' });
487
- res.end(JSON.stringify({ ok: true, fired: fired.map((f) => f.jobName), runs: fired }));
535
+ res.end(JSON.stringify({
536
+ ok: true,
537
+ fired: firedJobs.map((f) => f.jobName),
538
+ runs: firedJobs,
539
+ handlers: firedHandlers,
540
+ ...(handlerErrors.length > 0 ? { handlerErrors } : {}),
541
+ }));
488
542
  }
489
543
  catch (err) {
490
544
  res.writeHead(400, { 'content-type': 'application/json' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.88",
3
+ "version": "1.20.89",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",