@phnx-labs/agents-cli 1.22.40 → 1.22.41

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 (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +0 -1
  5. package/dist/cli/command-registry.js +1 -2
  6. package/dist/commands/browser.d.ts +15 -0
  7. package/dist/commands/browser.js +115 -40
  8. package/dist/commands/feed.js +3 -11
  9. package/dist/commands/secrets.js +87 -97
  10. package/dist/commands/sessions-picker.d.ts +1 -0
  11. package/dist/commands/sessions-picker.js +27 -5
  12. package/dist/commands/sessions-share.d.ts +25 -0
  13. package/dist/commands/sessions-share.js +166 -0
  14. package/dist/commands/sessions.js +2 -0
  15. package/dist/commands/setup-browser.js +1 -1
  16. package/dist/commands/setup-preferences.js +2 -2
  17. package/dist/commands/webhook.js +14 -9
  18. package/dist/lib/browser/cdp.js +4 -0
  19. package/dist/lib/browser/chrome.js +12 -1
  20. package/dist/lib/browser/drivers/ssh.js +1 -0
  21. package/dist/lib/browser/profiles.d.ts +3 -3
  22. package/dist/lib/browser/profiles.js +7 -7
  23. package/dist/lib/browser/service.d.ts +24 -1
  24. package/dist/lib/browser/service.js +38 -14
  25. package/dist/lib/browser/types.d.ts +1 -1
  26. package/dist/lib/daemon-webhooks.d.ts +3 -1
  27. package/dist/lib/daemon-webhooks.js +12 -7
  28. package/dist/lib/device-config.js +1 -1
  29. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  31. package/dist/lib/observe-aliases.d.ts +2 -2
  32. package/dist/lib/observe-aliases.js +2 -11
  33. package/dist/lib/project-key.js +7 -0
  34. package/dist/lib/runner.js +21 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/bundles.d.ts +1 -1
  38. package/dist/lib/secrets/bundles.js +1 -1
  39. package/dist/lib/secrets/headless.d.ts +16 -0
  40. package/dist/lib/secrets/headless.js +21 -0
  41. package/dist/lib/secrets/remote.d.ts +9 -7
  42. package/dist/lib/secrets/remote.js +18 -9
  43. package/dist/lib/session/share-html.d.ts +55 -0
  44. package/dist/lib/session/share-html.js +319 -0
  45. package/dist/lib/settings-manifest.d.ts +2 -0
  46. package/dist/lib/settings-manifest.js +81 -3
  47. package/dist/lib/share/publish.d.ts +38 -0
  48. package/dist/lib/share/publish.js +81 -8
  49. package/dist/lib/startup/command-registry.d.ts +2 -1
  50. package/dist/lib/startup/command-registry.js +4 -2
  51. package/dist/lib/triggers/handlers.d.ts +37 -2
  52. package/dist/lib/triggers/handlers.js +56 -5
  53. package/dist/lib/triggers/webhook.d.ts +80 -6
  54. package/dist/lib/triggers/webhook.js +127 -3
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/wrap.d.ts +33 -0
  57. package/dist/lib/wrap.js +70 -0
  58. package/package.json +1 -1
@@ -129,6 +129,25 @@ export declare function assertMetadataSize(customMetadata: Record<string, string
129
129
  * an unhandled `TypeError: Invalid value` and crashes the publish outright.
130
130
  */
131
131
  export declare function sanitizeLabel(text: string): string;
132
+ /**
133
+ * Make a free-text value safe to put in an HTTP header.
134
+ *
135
+ * `fetch` encodes header values as a **ByteString**, so any code point above 255
136
+ * throws `TypeError: Cannot convert argument to a ByteString` — an unhandled
137
+ * crash with a stack trace, mid-publish, after the body has already been read.
138
+ * Reproduced by publishing a session whose title ended in `…` (U+2026), and
139
+ * reachable by any emoji, curly quote, accented name, or CJK text in a `--label`,
140
+ * a `--meta` value, or a repo name.
141
+ *
142
+ * The transliterations above cover what actually shows up; anything else outside
143
+ * latin1 is dropped, and a value that transliterates to nothing at all (a title
144
+ * written entirely in a non-latin script) degrades to a marker rather than an
145
+ * empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
146
+ * here AND a matching decode in the Worker, which every already-deployed Worker
147
+ * would render as `%E2%80%A6` until its operator ran `agents artifacts share
148
+ * update` — tracked as RUSH-2786.
149
+ */
150
+ export declare function toHeaderValue(text: string): string;
132
151
  /**
133
152
  * Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
134
153
  * Markdown frontmatter `title:`, else the filename. Always returns something —
@@ -150,6 +169,25 @@ export interface SensitiveHit {
150
169
  /** Short redacted sample so the error names *what* was found without dumping it. */
151
170
  sample: string;
152
171
  }
172
+ /**
173
+ * Mask every email address in `text`, so a page can be published without tripping
174
+ * {@link scanShareContent}.
175
+ *
176
+ * Lives here, beside the scanner, rather than in `lib/redact.ts`: it exists solely
177
+ * to satisfy this gate, and sharing {@link EMAIL_RE} is what makes the masking
178
+ * *sufficient* to clear it rather than merely reducing the hit count. Two copies of
179
+ * the pattern in two modules would drift, and the failure mode of that drift is a
180
+ * refused publish at runtime.
181
+ *
182
+ * Deliberately NOT part of `redactSecrets`: an email is not a credential, and a
183
+ * transcript rendered for a private gist or local review reads better with the real
184
+ * author addresses intact. It only becomes a leak once the text is PUBLISHED — a
185
+ * world-readable page carrying seven of them is the RUSH-2428 incident.
186
+ *
187
+ * The whole address goes, domain included — a personal domain identifies its owner
188
+ * as surely as the local part does, and keeping it would buy the reader nothing.
189
+ */
190
+ export declare function redactEmails(text: string): string;
153
191
  /**
154
192
  * Scan a text body for email addresses and credential-shaped strings. Returns
155
193
  * the first few hits (deduped by kind+sample). Binary / non-text bodies yield
@@ -91,6 +91,49 @@ export function assertMetadataSize(customMetadata) {
91
91
  export function sanitizeLabel(text) {
92
92
  return text.replace(/\s+/g, ' ').trim();
93
93
  }
94
+ /**
95
+ * Typographic characters that reach a header constantly — a curly quote from a
96
+ * pasted prompt, an em dash from prose, the ellipsis a truncated title ends on —
97
+ * each mapped to the ASCII form a reader loses nothing by seeing.
98
+ */
99
+ const HEADER_TRANSLITERATIONS = [
100
+ [/[‘’‚‛]/g, "'"],
101
+ [/[“”„‟]/g, '"'],
102
+ [/[–—―]/g, '-'],
103
+ [/…/g, '...'],
104
+ [/[    ]/g, ' '],
105
+ [/[•·]/g, '-'],
106
+ ];
107
+ /**
108
+ * Make a free-text value safe to put in an HTTP header.
109
+ *
110
+ * `fetch` encodes header values as a **ByteString**, so any code point above 255
111
+ * throws `TypeError: Cannot convert argument to a ByteString` — an unhandled
112
+ * crash with a stack trace, mid-publish, after the body has already been read.
113
+ * Reproduced by publishing a session whose title ended in `…` (U+2026), and
114
+ * reachable by any emoji, curly quote, accented name, or CJK text in a `--label`,
115
+ * a `--meta` value, or a repo name.
116
+ *
117
+ * The transliterations above cover what actually shows up; anything else outside
118
+ * latin1 is dropped, and a value that transliterates to nothing at all (a title
119
+ * written entirely in a non-latin script) degrades to a marker rather than an
120
+ * empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
121
+ * here AND a matching decode in the Worker, which every already-deployed Worker
122
+ * would render as `%E2%80%A6` until its operator ran `agents artifacts share
123
+ * update` — tracked as RUSH-2786.
124
+ */
125
+ export function toHeaderValue(text) {
126
+ let safe = text;
127
+ for (const [pattern, replacement] of HEADER_TRANSLITERATIONS)
128
+ safe = safe.replace(pattern, replacement);
129
+ safe = safe.replace(/[^\x20-\x7E\xA0-\xFF]/g, '').replace(/\s+/g, ' ').trim();
130
+ // An input that was empty to begin with stays empty — `--meta note=` means an
131
+ // empty note, not an unnamed one. The marker is only for a value that HAD
132
+ // content and lost all of it to the latin1 fold.
133
+ if (safe)
134
+ return safe;
135
+ return text.trim() ? '(unnamed)' : '';
136
+ }
94
137
  /**
95
138
  * Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
96
139
  * Markdown frontmatter `title:`, else the filename. Always returns something —
@@ -141,6 +184,27 @@ export function resolveExpire(spec) {
141
184
  }
142
185
  /** Email addresses — the RUSH-2428 incident page carried seven of these. */
143
186
  const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
187
+ /**
188
+ * Mask every email address in `text`, so a page can be published without tripping
189
+ * {@link scanShareContent}.
190
+ *
191
+ * Lives here, beside the scanner, rather than in `lib/redact.ts`: it exists solely
192
+ * to satisfy this gate, and sharing {@link EMAIL_RE} is what makes the masking
193
+ * *sufficient* to clear it rather than merely reducing the hit count. Two copies of
194
+ * the pattern in two modules would drift, and the failure mode of that drift is a
195
+ * refused publish at runtime.
196
+ *
197
+ * Deliberately NOT part of `redactSecrets`: an email is not a credential, and a
198
+ * transcript rendered for a private gist or local review reads better with the real
199
+ * author addresses intact. It only becomes a leak once the text is PUBLISHED — a
200
+ * world-readable page carrying seven of them is the RUSH-2428 incident.
201
+ *
202
+ * The whole address goes, domain included — a personal domain identifies its owner
203
+ * as surely as the local part does, and keeping it would buy the reader nothing.
204
+ */
205
+ export function redactEmails(text) {
206
+ return text.replace(EMAIL_RE, '[EMAIL]');
207
+ }
144
208
  /**
145
209
  * Credential-shaped strings that an agent routinely dumps into reports: GitHub
146
210
  * PATs, OpenAI/Anthropic/etc. API keys, AWS access keys, Slack tokens, and a
@@ -430,20 +494,29 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
430
494
  h['x-share-expires-at'] = expiresAt;
431
495
  if (unlisted)
432
496
  h['x-share-visibility'] = 'unlisted';
497
+ // Every free-text header goes through toHeaderValue: a non-latin1 code point
498
+ // anywhere in a label, a repo name, or a --meta value throws inside fetch and
499
+ // crashes the publish outright.
433
500
  if (provenance.agent)
434
- h['x-share-agent'] = provenance.agent;
501
+ h['x-share-agent'] = toHeaderValue(provenance.agent);
435
502
  if (provenance.session)
436
- h['x-share-session'] = provenance.session;
503
+ h['x-share-session'] = toHeaderValue(provenance.session);
437
504
  if (provenance.host)
438
- h['x-share-host'] = provenance.host;
505
+ h['x-share-host'] = toHeaderValue(provenance.host);
439
506
  if (provenance.repo)
440
- h['x-share-repo'] = provenance.repo;
507
+ h['x-share-repo'] = toHeaderValue(provenance.repo);
441
508
  if (provenance.date)
442
- h['x-share-date'] = provenance.date;
443
- h['x-share-label'] = label;
509
+ h['x-share-date'] = toHeaderValue(provenance.date);
510
+ h['x-share-label'] = toHeaderValue(label);
444
511
  h['x-share-label-source'] = labelSource;
445
- if (Object.keys(meta).length > 0)
446
- h['x-share-meta'] = JSON.stringify(meta);
512
+ // Per VALUE, before JSON.stringify folding the serialized form would rewrite
513
+ // a curly quote inside a value into a bare `"`, which is structural in JSON and
514
+ // makes the Worker's JSON.parse throw. It swallows that error, so every --meta
515
+ // key would silently vanish on a 200.
516
+ if (Object.keys(meta).length > 0) {
517
+ const headerMeta = Object.fromEntries(Object.entries(meta).map(([k, v]) => [toHeaderValue(k), toHeaderValue(v)]));
518
+ h['x-share-meta'] = JSON.stringify(headerMeta);
519
+ }
447
520
  if (opts.noRevision)
448
521
  h['x-share-no-revision'] = '1';
449
522
  return h;
@@ -20,7 +20,8 @@ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
20
20
  * `set` moved under `agents models`/`agents config` (RUSH-2579); `share` moved
21
21
  * under `agents artifacts share` (RUSH-2580). login/logout/budget/bench/mine/
22
22
  * cost/output/profiles/snapshot/cp/resume/roster moved under nested homes
23
- * (cli-surface-consolidate).
23
+ * (cli-surface-consolidate). `timeline` was removed as a duplicated surface —
24
+ * use `agents feed --filter updates` (RUSH-2692).
24
25
  */
25
26
  export declare const RETIRED_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
26
27
  export declare function isKnownTopLevelCommand(name: string): boolean;
@@ -9,7 +9,7 @@ const LOADED_COMMAND_NAMES = [
9
9
  'alias', 'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
10
10
  'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
11
11
  'teams', 'tickets', 'cloud', 'message', 'send', 'notify', 'feed', 'inbox',
12
- 'timeline', 'mailboxes', 'mailbox', 'serve', 'artifacts', 'unshare', 'audit', 'webhooks',
12
+ 'mailboxes', 'mailbox', 'serve', 'artifacts', 'unshare', 'audit', 'webhooks',
13
13
  'humans', 'daemon',
14
14
  ];
15
15
  const INLINE_COMMAND_NAMES = [
@@ -40,7 +40,8 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
40
40
  * `set` moved under `agents models`/`agents config` (RUSH-2579); `share` moved
41
41
  * under `agents artifacts share` (RUSH-2580). login/logout/budget/bench/mine/
42
42
  * cost/output/profiles/snapshot/cp/resume/roster moved under nested homes
43
- * (cli-surface-consolidate).
43
+ * (cli-surface-consolidate). `timeline` was removed as a duplicated surface —
44
+ * use `agents feed --filter updates` (RUSH-2692).
44
45
  */
45
46
  export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
46
47
  'webhook',
@@ -58,6 +59,7 @@ export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
58
59
  'roster',
59
60
  'set',
60
61
  'share',
62
+ 'timeline',
61
63
  ]);
62
64
  export function isKnownTopLevelCommand(name) {
63
65
  return KNOWN_TOP_LEVEL_COMMANDS.has(name);
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { JobConfig, RunMeta, WebhookContext } from '../scheduling/routines.js';
9
9
  import type { AgentId } from '../types.js';
10
- import type { IncomingWebhook, WebhookSource } from './webhook.js';
10
+ import type { IncomingWebhook, SlackPayload, WebhookSource } from './webhook.js';
11
11
  export interface WebhookHandler {
12
12
  name: string;
13
13
  enabled?: boolean;
@@ -21,6 +21,11 @@ export interface WebhookHandler {
21
21
  label?: string;
22
22
  repo?: string;
23
23
  branch?: string;
24
+ /** Slack (source: slack) only — match one slash command, e.g. `/agents`. */
25
+ command?: string;
26
+ /** Slack (source: slack) only — restrict to one channel id (`C0…`). Lets a
27
+ * channel imply a project via a per-channel handler. */
28
+ channel?: string;
24
29
  /**
25
30
  * Where to run the action. A device name (`yosemite-s0`) runs there over SSH;
26
31
  * `fleet` picks any eligible online worker; `fleet/<platform>` (or
@@ -92,10 +97,40 @@ export declare function resolveHandlerHost(host: string | undefined): HandlerHos
92
97
  export declare function listHandlers(cwd?: string): WebhookHandler[];
93
98
  /** Pure matcher: does this handler match the incoming webhook? */
94
99
  export declare function handlerMatchesWebhook(handler: WebhookHandler, webhook: IncomingWebhook): boolean;
100
+ /**
101
+ * The `{{slack.*}}` substitution namespace: a Slack message split into an
102
+ * agent-actionable project + prompt, plus the coordinates a reply threads into.
103
+ */
104
+ export interface SlackMessageContext {
105
+ /** The message with a leading bot mention stripped. */
106
+ text: string;
107
+ /** The `PROJECT:` token at the head of the message, or '' when absent. */
108
+ project: string;
109
+ /** The request — the text after `PROJECT:`, or the whole message. */
110
+ prompt: string;
111
+ /** Channel id to reply into (`C0…`). */
112
+ channel: string;
113
+ /** Thread ts to reply into (empty for a slash command → reply to the channel). */
114
+ thread_ts: string;
115
+ /** Invoking user id (`U0…`). */
116
+ user: string;
117
+ /** Slash command name (`/agents`), or '' for an event delivery. */
118
+ command: string;
119
+ }
120
+ /**
121
+ * Split a Slack message into a `{{slack.*}}` context. Strips a leading bot
122
+ * mention (`<@U0BOT> …`), then reads an optional `PROJECT:` prefix — a single
123
+ * bare token followed by a colon and a space — as the project, with the
124
+ * remainder the prompt. `AGI: rebase my PR` → project `AGI`, prompt
125
+ * `rebase my PR`; a message with no such prefix leaves `project` empty and the
126
+ * whole text as the prompt. The token pattern forbids slashes, so a project
127
+ * value can never carry a path into a `cwd`/`project` substitution.
128
+ */
129
+ export declare function parseSlackMessage(payload: SlackPayload): SlackMessageContext;
95
130
  /**
96
131
  * Build the variable-substitution context for a webhook. Linear events expose
97
132
  * `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
98
- * and `issue`.
133
+ * and `issue`; Slack events expose the `{{slack.*}}` namespace above.
99
134
  */
100
135
  export declare function buildWebhookContext(webhook: IncomingWebhook): WebhookContext;
101
136
  export interface ExecuteHandlerOptions {
@@ -208,6 +208,17 @@ export function handlerMatchesWebhook(handler, webhook) {
208
208
  return false;
209
209
  if (!handlerRunsOnThisDevice(handler))
210
210
  return false;
211
+ // Slack has no GitHub/Linear action/label/branch vocabulary; it matches on the
212
+ // slash command and (optionally) the channel. `event` already pinned the
213
+ // subtype (`app_mention`) or the slash command name above.
214
+ if (webhook.source === 'slack') {
215
+ const slack = webhook.payload;
216
+ if (handler.command && slack.command !== handler.command)
217
+ return false;
218
+ if (handler.channel && slack.channel !== handler.channel)
219
+ return false;
220
+ return true;
221
+ }
211
222
  if (handler.action) {
212
223
  const action = webhook.source === 'github' ? githubAction(webhook.payload) : linearAction(webhook.payload);
213
224
  if (action !== handler.action)
@@ -262,12 +273,47 @@ export function handlerMatchesWebhook(handler, webhook) {
262
273
  }
263
274
  return true;
264
275
  }
276
+ const asString = (v) => (typeof v === 'string' ? v : '');
277
+ /**
278
+ * Split a Slack message into a `{{slack.*}}` context. Strips a leading bot
279
+ * mention (`<@U0BOT> …`), then reads an optional `PROJECT:` prefix — a single
280
+ * bare token followed by a colon and a space — as the project, with the
281
+ * remainder the prompt. `AGI: rebase my PR` → project `AGI`, prompt
282
+ * `rebase my PR`; a message with no such prefix leaves `project` empty and the
283
+ * whole text as the prompt. The token pattern forbids slashes, so a project
284
+ * value can never carry a path into a `cwd`/`project` substitution.
285
+ */
286
+ export function parseSlackMessage(payload) {
287
+ const cleaned = asString(payload.text).replace(/^\s*<@[^>]+>\s*/, '').trim();
288
+ const m = /^([A-Za-z0-9][\w.-]*)\s*:\s+([\s\S]+)$/.exec(cleaned);
289
+ return {
290
+ text: cleaned,
291
+ project: m ? m[1] : '',
292
+ prompt: m ? m[2].trim() : cleaned,
293
+ channel: asString(payload.channel),
294
+ thread_ts: asString(payload.thread_ts),
295
+ user: asString(payload.user),
296
+ command: asString(payload.command),
297
+ };
298
+ }
265
299
  /**
266
300
  * Build the variable-substitution context for a webhook. Linear events expose
267
301
  * `issue` and `updatedFrom`; GitHub events expose `repository`, `pull_request`,
268
- * and `issue`.
302
+ * and `issue`; Slack events expose the `{{slack.*}}` namespace above.
269
303
  */
270
304
  export function buildWebhookContext(webhook) {
305
+ if (webhook.source === 'slack') {
306
+ // The `{{slack.*}}` namespace rides an intersection over WebhookContext (a
307
+ // subtype of it) rather than a field on the shared type, so a Slack-only
308
+ // addition never touches the scheduling hub every routine test depends on.
309
+ // `substituteWebhookPrompt`/`getPath` read it dynamically at runtime.
310
+ const ctx = {
311
+ source: webhook.source,
312
+ event: webhook.event,
313
+ slack: parseSlackMessage(webhook.payload),
314
+ };
315
+ return ctx;
316
+ }
271
317
  const action = webhook.source === 'github'
272
318
  ? githubAction(webhook.payload) ?? undefined
273
319
  : linearAction(webhook.payload) ?? undefined;
@@ -346,6 +392,11 @@ export async function executeHandler(handler, webhook, opts = {}) {
346
392
  }
347
393
  async function executeHandlerAction(handler, webhook, context, opts) {
348
394
  const substitutedPrompt = handler.run?.prompt ? substituteWebhookPrompt(handler.run.prompt, context) : '';
395
+ // Slack routes the project per-message (`AGI: …`), so a handler may template
396
+ // its project/cwd — e.g. `project: "{{slack.project}}"`. Substitute + trim; an
397
+ // empty result omits the field (falls back to the run's default cwd / $HOME).
398
+ const substitutedProject = handler.project ? substituteWebhookPrompt(handler.project, context).trim() : '';
399
+ const substitutedCwd = handler.cwd ? substituteWebhookPrompt(handler.cwd, context).trim() : '';
349
400
  // Resolved once so a fleet pick can't differ between the two dispatch paths.
350
401
  const hostFields = resolveHandlerHost(handler.host);
351
402
  if (handler.run?.agent || handler.run?.workflow) {
@@ -359,8 +410,8 @@ async function executeHandlerAction(handler, webhook, context, opts) {
359
410
  ...(handler.run.agent ? { agent: handler.run.agent } : { workflow: handler.run.workflow }),
360
411
  ...(handler.devices ? { devices: handler.devices } : {}),
361
412
  ...(handler.run.env ? { env: handler.run.env } : {}),
362
- ...(handler.project ? { project: handler.project } : {}),
363
- ...(handler.cwd ? { cwd: handler.cwd } : {}),
413
+ ...(substitutedProject ? { project: substitutedProject } : {}),
414
+ ...(substitutedCwd ? { cwd: substitutedCwd } : {}),
364
415
  ...(hostFields.host ? { host: hostFields.host } : {}),
365
416
  ...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
366
417
  };
@@ -385,8 +436,8 @@ async function executeHandlerAction(handler, webhook, context, opts) {
385
436
  ...routine,
386
437
  prompt: substituteWebhookPrompt(routine.prompt, context),
387
438
  ...(handler.devices ? { devices: handler.devices } : {}),
388
- ...(handler.project ? { project: handler.project } : {}),
389
- ...(handler.cwd ? { cwd: handler.cwd } : {}),
439
+ ...(substitutedProject ? { project: substitutedProject } : {}),
440
+ ...(substitutedCwd ? { cwd: substitutedCwd } : {}),
390
441
  ...(handler.mode ? { mode: handler.mode } : {}),
391
442
  ...(hostFields.host ? { host: hostFields.host } : {}),
392
443
  ...(hostFields.hostStrategy ? { hostStrategy: hostFields.hostStrategy } : {}),
@@ -15,13 +15,15 @@ import * as http from 'http';
15
15
  import type { IncomingHttpHeaders } from 'http';
16
16
  import type { JobConfig, RunMeta, WebhookContext } from '../scheduling/routines.js';
17
17
  import { type FiredHandler } from './handlers.js';
18
- export type WebhookSource = 'github' | 'linear';
18
+ export type WebhookSource = 'github' | 'linear' | 'slack';
19
19
  export interface IncomingWebhook {
20
20
  /** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
21
21
  source: WebhookSource;
22
- /** Source event name: GitHub header event or Linear payload `type`. */
22
+ /** Source event name: GitHub header event, Linear payload `type`, or the Slack
23
+ * event subtype (`app_mention`) / slash command (`/agents`). */
23
24
  event: string;
24
- /** Decoded JSON request body. */
25
+ /** Decoded request body (JSON for GitHub/Linear/Slack-events; the normalized
26
+ * {@link SlackPayload} for a Slack slash command's form body). */
25
27
  payload: Record<string, unknown>;
26
28
  }
27
29
  export type GithubWebhook = IncomingWebhook & {
@@ -30,6 +32,9 @@ export type GithubWebhook = IncomingWebhook & {
30
32
  export type LinearWebhook = IncomingWebhook & {
31
33
  source: 'linear';
32
34
  };
35
+ export type SlackWebhook = IncomingWebhook & {
36
+ source: 'slack';
37
+ };
33
38
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
34
39
  export declare function webhookRepo(payload: Record<string, unknown>): string | null;
35
40
  /**
@@ -96,9 +101,59 @@ export declare function fireWebhookJobs(webhook: IncomingWebhook, options?: Fire
96
101
  export declare function verifyGithubSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
97
102
  export declare function verifyLinearSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
98
103
  export declare function verifyLinearTimestamp(payload: Record<string, unknown>, now?: number, toleranceMs?: number): boolean;
104
+ /**
105
+ * Verify a Slack request signature (the `v0` scheme). Slack signs the base
106
+ * string `v0:${timestamp}:${rawBody}` with the app's signing secret and sends
107
+ * the hex digest as `X-Slack-Signature: v0=<hex>`, alongside the unix
108
+ * `X-Slack-Request-Timestamp`. The timestamp is BOTH part of the signed base
109
+ * string AND checked for freshness here — a request older than `toleranceSec`
110
+ * (default 5 min) is rejected, so a captured, correctly-signed body cannot be
111
+ * replayed later. Fails closed on any missing/malformed header.
112
+ */
113
+ export declare function verifySlackSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string, now?: number, toleranceSec?: number): boolean;
114
+ /**
115
+ * Normalized fields extracted from a Slack slash-command or Events API delivery.
116
+ * This is the `payload` of a Slack {@link IncomingWebhook}: transport-level
117
+ * parsing lives here, message *semantics* (splitting the mention text into an
118
+ * agent / project / prompt) live in `handlers.ts` `buildWebhookContext`.
119
+ */
120
+ export interface SlackPayload extends Record<string, unknown> {
121
+ /** `url_verification` | `event_callback` | `slash_command`. */
122
+ type: string;
123
+ /** Echoed back for the one-time Events API URL-verification handshake. */
124
+ challenge?: string;
125
+ /** Slack event id (Events API) — the dedup key when present. */
126
+ event_id?: string;
127
+ /** Event subtype, e.g. `app_mention` (Events API). */
128
+ event_type?: string;
129
+ /** Raw message text (the mention body, or the slash-command text). */
130
+ text?: string;
131
+ /** Channel id (`C0…`) the message arrived in. */
132
+ channel?: string;
133
+ /** Thread to reply into: an existing thread's parent ts, else the message ts. */
134
+ thread_ts?: string;
135
+ /** Invoking user id (`U0…`). */
136
+ user?: string;
137
+ /** Slash command name, e.g. `/agents`. */
138
+ command?: string;
139
+ /** Slash-command `response_url` (valid ~30 min, 5 uses). */
140
+ response_url?: string;
141
+ /** Slack team / workspace id. */
142
+ team?: string;
143
+ }
144
+ /**
145
+ * Parse a Slack delivery body into a normalized {@link SlackPayload}. Slack
146
+ * sends slash commands as `application/x-www-form-urlencoded` and Events API
147
+ * deliveries (including the `url_verification` handshake) as
148
+ * `application/json` — this is the one place that content-type branch lives.
149
+ */
150
+ export declare function parseSlackBody(contentType: string | undefined, rawBody: Buffer): SlackPayload;
151
+ /** The `event` name a Slack delivery fires under: the slash command, or the event subtype. */
152
+ export declare function slackEventName(payload: SlackPayload): string;
99
153
  export interface WebhookSecrets {
100
154
  github?: string;
101
155
  linear?: string;
156
+ slack?: string;
102
157
  }
103
158
  export interface DeliveryStore {
104
159
  seen(id: string): boolean;
@@ -140,11 +195,25 @@ export interface WebhookServerOptions {
140
195
  secrets: WebhookSecrets;
141
196
  /** Override the fire options (mainly for tests). */
142
197
  fire?: FireWebhookOptions;
198
+ /**
199
+ * Called the moment a delivery's matches are known — BEFORE any routine or
200
+ * handler is dispatched. Matching is a pure, synchronous lookup (no I/O), so
201
+ * this fires immediately after the ack, regardless of how long the matched
202
+ * work then takes to run. This is the right hook for a "webhook X fired Y"
203
+ * log line: a `run.command` handler that shells out a long agent session
204
+ * (`exec()`, which only resolves on process exit) used to hold that log back
205
+ * for as long as the session ran — `ps` would show the agent already running
206
+ * while the log still implied nothing had matched (RUSH-2722). Names only
207
+ * (no runId/exitCode yet — those aren't known until dispatch settles).
208
+ */
209
+ onMatch?: (webhook: IncomingWebhook, matchedJobNames: string[], matchedHandlerNames: string[]) => void;
143
210
  /**
144
211
  * Called after a delivery has fully settled — every matched routine and
145
- * handler dispatched. Because the receiver acks the HTTP response BEFORE
146
- * dispatch (see `startWebhookServer`), this fires strictly after the response
147
- * has been written, and is the only way a caller observes the outcome.
212
+ * handler dispatched (and, for `run.command` handlers, exited). Because the
213
+ * receiver acks the HTTP response BEFORE dispatch (see `startWebhookServer`),
214
+ * this fires strictly after the response has been written, and is the only
215
+ * way a caller observes the dispatch outcome (runId/exitCode/output). It is
216
+ * NOT the right hook for "did this webhook match" logging — use `onMatch`.
148
217
  */
149
218
  onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[], handlers: FiredHandler[]) => void;
150
219
  /**
@@ -183,6 +252,11 @@ export interface WebhookServerOptions {
183
252
  * duplicate from an in-flight set, since `deliveryStore.seen` only reports
184
253
  * completed deliveries and would otherwise let a mid-flight retry double-fire.
185
254
  *
255
+ * `onMatch` fires as soon as matching is known, before any dispatch; `onDelivery`
256
+ * fires only once every matched routine/handler has settled — see their docs on
257
+ * `WebhookServerOptions`. A caller that wants a prompt "this fired" log uses
258
+ * `onMatch`, not `onDelivery` (RUSH-2722).
259
+ *
186
260
  * Returns the underlying server so callers can `close()` it.
187
261
  */
188
262
  export declare function startWebhookServer(options: WebhookServerOptions): http.Server;