@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
@@ -264,6 +264,76 @@ export function verifyLinearTimestamp(payload, now = Date.now(), toleranceMs = 6
264
264
  const ts = payload.webhookTimestamp;
265
265
  return typeof ts === 'number' && Math.abs(now - ts) <= toleranceMs;
266
266
  }
267
+ /**
268
+ * Verify a Slack request signature (the `v0` scheme). Slack signs the base
269
+ * string `v0:${timestamp}:${rawBody}` with the app's signing secret and sends
270
+ * the hex digest as `X-Slack-Signature: v0=<hex>`, alongside the unix
271
+ * `X-Slack-Request-Timestamp`. The timestamp is BOTH part of the signed base
272
+ * string AND checked for freshness here — a request older than `toleranceSec`
273
+ * (default 5 min) is rejected, so a captured, correctly-signed body cannot be
274
+ * replayed later. Fails closed on any missing/malformed header.
275
+ */
276
+ export function verifySlackSignature(headers, rawBody, secret, now = Date.now(), toleranceSec = 300) {
277
+ const ts = header(headers, 'x-slack-request-timestamp');
278
+ if (!ts || !/^\d+$/.test(ts))
279
+ return false;
280
+ if (Math.abs(Math.floor(now / 1000) - Number(ts)) > toleranceSec)
281
+ return false;
282
+ const received = header(headers, 'x-slack-signature');
283
+ const signature = received?.startsWith('v0=') ? received.slice('v0='.length) : undefined;
284
+ // Concatenate the base-string prefix with the raw body bytes so a non-ASCII
285
+ // payload hashes identically to Slack's own `v0:${ts}:${body}` string.
286
+ const base = Buffer.concat([Buffer.from(`v0:${ts}:`, 'utf-8'), rawBody]);
287
+ const expected = crypto.createHmac('sha256', secret).update(base).digest('hex');
288
+ return timingSafeHexEqual(signature, expected);
289
+ }
290
+ /**
291
+ * Parse a Slack delivery body into a normalized {@link SlackPayload}. Slack
292
+ * sends slash commands as `application/x-www-form-urlencoded` and Events API
293
+ * deliveries (including the `url_verification` handshake) as
294
+ * `application/json` — this is the one place that content-type branch lives.
295
+ */
296
+ export function parseSlackBody(contentType, rawBody) {
297
+ if ((contentType ?? '').includes('application/x-www-form-urlencoded')) {
298
+ const form = new URLSearchParams(rawBody.toString('utf-8'));
299
+ // A slash command carries no thread; its reply posts to the channel.
300
+ return {
301
+ type: 'slash_command',
302
+ command: form.get('command') ?? undefined,
303
+ text: form.get('text') ?? undefined,
304
+ channel: form.get('channel_id') ?? undefined,
305
+ user: form.get('user_id') ?? undefined,
306
+ response_url: form.get('response_url') ?? undefined,
307
+ team: form.get('team_id') ?? undefined,
308
+ };
309
+ }
310
+ const json = rawBody.length > 0 ? JSON.parse(rawBody.toString('utf-8')) : {};
311
+ const type = String(json.type ?? '');
312
+ if (type === 'url_verification') {
313
+ return { type, challenge: typeof json.challenge === 'string' ? json.challenge : '' };
314
+ }
315
+ const event = (json.event ?? {});
316
+ return {
317
+ type: type || 'event_callback',
318
+ event_id: typeof json.event_id === 'string' ? json.event_id : undefined,
319
+ event_type: typeof event.type === 'string' ? event.type : undefined,
320
+ text: typeof event.text === 'string' ? event.text : undefined,
321
+ channel: typeof event.channel === 'string' ? event.channel : undefined,
322
+ thread_ts: typeof event.thread_ts === 'string'
323
+ ? event.thread_ts
324
+ : typeof event.ts === 'string'
325
+ ? event.ts
326
+ : undefined,
327
+ user: typeof event.user === 'string' ? event.user : undefined,
328
+ team: typeof json.team_id === 'string' ? json.team_id : undefined,
329
+ };
330
+ }
331
+ /** The `event` name a Slack delivery fires under: the slash command, or the event subtype. */
332
+ export function slackEventName(payload) {
333
+ if (payload.type === 'slash_command')
334
+ return payload.command ?? 'slash_command';
335
+ return payload.event_type ?? payload.type;
336
+ }
267
337
  export function createMemoryDeliveryStore(maxEntries = 1000) {
268
338
  const seen = new Map();
269
339
  const touch = (id) => {
@@ -405,7 +475,7 @@ function deliveryId(source, headers, rawBody) {
405
475
  return `${source}:${named ?? crypto.createHash('sha256').update(rawBody).digest('hex')}`;
406
476
  }
407
477
  function sourceFromPath(pathname) {
408
- const match = /^\/hooks\/(github|linear)\/?$/.exec(pathname ?? '');
478
+ const match = /^\/hooks\/(github|linear|slack)\/?$/.exec(pathname ?? '');
409
479
  return match ? match[1] : null;
410
480
  }
411
481
  async function readRawBody(req, maxBytes) {
@@ -461,6 +531,11 @@ export function waitForListening(server) {
461
531
  * duplicate from an in-flight set, since `deliveryStore.seen` only reports
462
532
  * completed deliveries and would otherwise let a mid-flight retry double-fire.
463
533
  *
534
+ * `onMatch` fires as soon as matching is known, before any dispatch; `onDelivery`
535
+ * fires only once every matched routine/handler has settled — see their docs on
536
+ * `WebhookServerOptions`. A caller that wants a prompt "this fired" log uses
537
+ * `onMatch`, not `onDelivery` (RUSH-2722).
538
+ *
464
539
  * Returns the underlying server so callers can `close()` it.
465
540
  */
466
541
  export function startWebhookServer(options) {
@@ -482,8 +557,17 @@ export function startWebhookServer(options) {
482
557
  try {
483
558
  const context = buildWebhookContext(webhook);
484
559
  const fireOptions = options.fire ?? {};
560
+ // Match FIRST, before any dispatch — matching is a pure in-memory lookup,
561
+ // so this is the earliest point the receiver knows what fired, and the
562
+ // right time to log it (RUSH-2722). Dispatching a `run.command` handler
563
+ // can then block on the shelled-out process for minutes; that must never
564
+ // hold the "fired" log back with it.
565
+ const matchedJobs = matchJobsToWebhook(fireOptions.jobs ?? listJobs(), webhook);
566
+ const matchedHandlers = listHandlers().filter((handler) => handlerMatchesWebhook(handler, webhook));
567
+ options.onMatch?.(webhook, matchedJobs.map((job) => job.name), matchedHandlers.map((handler) => handler.name));
485
568
  const firedJobs = await fireWebhookJobs(webhook, {
486
569
  ...fireOptions,
570
+ jobs: matchedJobs,
487
571
  context,
488
572
  skipJobNames: deliveryStore.completedJobs(id),
489
573
  onJobFired: (job, firedJob) => {
@@ -492,7 +576,6 @@ export function startWebhookServer(options) {
492
576
  fireOptions.onJobFired?.(job, firedJob);
493
577
  },
494
578
  });
495
- const matchedHandlers = listHandlers().filter((handler) => handlerMatchesWebhook(handler, webhook));
496
579
  const firedHandlers = [];
497
580
  const handlerErrors = [];
498
581
  await Promise.allSettled(matchedHandlers.map(async (handler) => {
@@ -563,13 +646,54 @@ export function startWebhookServer(options) {
563
646
  const rawBody = await readRawBody(req, maxBodyBytes);
564
647
  const valid = source === 'github'
565
648
  ? verifyGithubSignature(req.headers, rawBody, secret)
566
- : verifyLinearSignature(req.headers, rawBody, secret);
649
+ : source === 'linear'
650
+ ? verifyLinearSignature(req.headers, rawBody, secret)
651
+ : verifySlackSignature(req.headers, rawBody, secret);
567
652
  if (!valid) {
568
653
  emit('webhook.rejected', { source, reason: 'invalid signature' });
569
654
  res.writeHead(401, { 'content-type': 'application/json' });
570
655
  res.end(JSON.stringify({ ok: false, error: 'invalid signature' }));
571
656
  return;
572
657
  }
658
+ // Slack diverges from the GitHub/Linear JSON path: two content-types
659
+ // (slash = form, events = JSON), a one-time url_verification handshake,
660
+ // a body-carried event id, and a 200 ack Slack shows to the caller.
661
+ if (source === 'slack') {
662
+ const slack = parseSlackBody(header(req.headers, 'content-type'), rawBody);
663
+ // One-time Events API URL handshake: echo the challenge, dispatch nothing.
664
+ if (slack.type === 'url_verification') {
665
+ res.writeHead(200, { 'content-type': 'application/json' });
666
+ res.end(JSON.stringify({ challenge: slack.challenge ?? '' }));
667
+ return;
668
+ }
669
+ const slackEvent = slackEventName(slack);
670
+ // Slash commands carry no event_id, so fall back to a body hash for dedup.
671
+ const slackId = `slack:${slack.event_id ?? crypto.createHash('sha256').update(rawBody).digest('hex')}`;
672
+ emit('webhook.received', { source, event: slackEvent, deliveryId: slackId });
673
+ if (deliveryStore.seen(slackId) || inFlight.has(slackId)) {
674
+ res.writeHead(200, { 'content-type': 'application/json' });
675
+ res.end(JSON.stringify({ ok: true, duplicate: true }));
676
+ return;
677
+ }
678
+ if (!rateLimiter.take(source)) {
679
+ emit('webhook.rejected', { source, event: slackEvent, deliveryId: slackId, reason: 'rate limit exceeded' });
680
+ res.writeHead(429, { 'content-type': 'application/json' });
681
+ res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
682
+ return;
683
+ }
684
+ const slackWebhook = { source, event: slackEvent, payload: slack };
685
+ emit('webhook.authorized', { source, event: slackEvent, deliveryId: slackId });
686
+ // ACK FIRST (RUSH-2548): a 200 inside Slack's 3s window. A slash
687
+ // command renders the ack text to the caller; an event delivery
688
+ // ignores the body. Dispatch outlives the ack, same as GitHub/Linear.
689
+ inFlight.add(slackId);
690
+ res.writeHead(200, { 'content-type': 'application/json' });
691
+ res.end(slack.type === 'slash_command'
692
+ ? JSON.stringify({ response_type: 'ephemeral', text: 'On it — replying in this thread.' })
693
+ : '');
694
+ void settleDelivery(slackWebhook, slackId).finally(() => inFlight.delete(slackId));
695
+ return;
696
+ }
573
697
  const id = deliveryId(source, req.headers, rawBody);
574
698
  const event = source === 'github' ? (header(req.headers, 'x-github-event') ?? '') : '';
575
699
  emit('webhook.received', { source, event, deliveryId: id });
@@ -1110,7 +1110,7 @@ export interface HostEntry {
1110
1110
  /** Browser profile definition stored in agents.yaml. */
1111
1111
  export interface BrowserProfileConfig {
1112
1112
  description?: string;
1113
- browser: 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'custom';
1113
+ browser: 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'arc' | 'custom';
1114
1114
  binary?: string;
1115
1115
  electron?: boolean;
1116
1116
  /**
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Terminal word-wrap — one shared helper for the whole CLI.
3
+ *
4
+ * Multi-line word wrapping is the gap the single-line helpers in
5
+ * `session/width.ts` (`truncateToWidth`, `padToWidth`) do not fill. Anywhere that
6
+ * renders variable-length text into a fixed-width terminal wraps through THIS,
7
+ * never a per-field slice.
8
+ *
9
+ * Width is measured with `stringWidth` (from the canonical `session/width.ts`),
10
+ * NOT `String.length`: the text this wraps is routinely ANSI-coloured (chalk /
11
+ * marked-terminal output) and may carry OSC-8 hyperlinks, where `.length`
12
+ * over-counts the invisible escape bytes. Words are split on whitespace, so a
13
+ * wrap boundary always falls between escape sequences, never inside one.
14
+ */
15
+ /**
16
+ * Hard-wrap `text` to `cols` visible columns.
17
+ *
18
+ * - Splits on existing newlines first (each paragraph wraps independently).
19
+ * - Wraps on whitespace; a single word wider than the available width is emitted
20
+ * on its own line rather than split mid-word (and never mid-escape).
21
+ * - Width is the visible display width via `stringWidth`, so ANSI colour and
22
+ * OSC-8 hyperlink escapes do not inflate the count.
23
+ * - `hangingIndent` pads every line AFTER the first with that many spaces, so a
24
+ * labelled value ("Latest <text>") keeps its continuation lines aligned under
25
+ * the value. The wrapping width is `cols - indent`.
26
+ * - Floor: when `hangingIndent` is set, the width is floored at 8 so a
27
+ * pathologically large indent still makes forward progress. With no indent the
28
+ * floor is 1, so a legitimately small `cols` is respected, never overridden. A
29
+ * non-finite `cols` degrades to the floor rather than silently disabling wrap.
30
+ *
31
+ * Returns the wrapped lines (never mutates input). An empty string yields [''].
32
+ */
33
+ export declare function wrapToWidth(text: string, cols: number, hangingIndent?: number): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Terminal word-wrap — one shared helper for the whole CLI.
3
+ *
4
+ * Multi-line word wrapping is the gap the single-line helpers in
5
+ * `session/width.ts` (`truncateToWidth`, `padToWidth`) do not fill. Anywhere that
6
+ * renders variable-length text into a fixed-width terminal wraps through THIS,
7
+ * never a per-field slice.
8
+ *
9
+ * Width is measured with `stringWidth` (from the canonical `session/width.ts`),
10
+ * NOT `String.length`: the text this wraps is routinely ANSI-coloured (chalk /
11
+ * marked-terminal output) and may carry OSC-8 hyperlinks, where `.length`
12
+ * over-counts the invisible escape bytes. Words are split on whitespace, so a
13
+ * wrap boundary always falls between escape sequences, never inside one.
14
+ */
15
+ import { stringWidth } from './session/width.js';
16
+ /**
17
+ * Hard-wrap `text` to `cols` visible columns.
18
+ *
19
+ * - Splits on existing newlines first (each paragraph wraps independently).
20
+ * - Wraps on whitespace; a single word wider than the available width is emitted
21
+ * on its own line rather than split mid-word (and never mid-escape).
22
+ * - Width is the visible display width via `stringWidth`, so ANSI colour and
23
+ * OSC-8 hyperlink escapes do not inflate the count.
24
+ * - `hangingIndent` pads every line AFTER the first with that many spaces, so a
25
+ * labelled value ("Latest <text>") keeps its continuation lines aligned under
26
+ * the value. The wrapping width is `cols - indent`.
27
+ * - Floor: when `hangingIndent` is set, the width is floored at 8 so a
28
+ * pathologically large indent still makes forward progress. With no indent the
29
+ * floor is 1, so a legitimately small `cols` is respected, never overridden. A
30
+ * non-finite `cols` degrades to the floor rather than silently disabling wrap.
31
+ *
32
+ * Returns the wrapped lines (never mutates input). An empty string yields [''].
33
+ */
34
+ export function wrapToWidth(text, cols, hangingIndent = 0) {
35
+ const indent = Math.max(0, Math.trunc(hangingIndent));
36
+ const floor = indent > 0 ? 8 : 1;
37
+ const width = Number.isFinite(cols) ? Math.max(floor, cols - indent) : floor;
38
+ const pad = ' '.repeat(indent);
39
+ const out = [];
40
+ for (const para of String(text).split('\n')) {
41
+ const words = para.split(/\s+/).filter(w => w.length > 0);
42
+ if (words.length === 0) {
43
+ out.push('');
44
+ continue;
45
+ }
46
+ let line = '';
47
+ let lineWidth = 0;
48
+ for (const word of words) {
49
+ const wordWidth = stringWidth(word);
50
+ if (line === '') {
51
+ line = word;
52
+ lineWidth = wordWidth;
53
+ }
54
+ else if (lineWidth + 1 + wordWidth > width) {
55
+ out.push(line);
56
+ line = word;
57
+ lineWidth = wordWidth;
58
+ }
59
+ else {
60
+ line = `${line} ${word}`;
61
+ lineWidth += 1 + wordWidth;
62
+ }
63
+ }
64
+ if (line !== '')
65
+ out.push(line);
66
+ }
67
+ if (out.length === 0)
68
+ out.push('');
69
+ return out.map((l, i) => (i === 0 ? l : pad + l));
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.40",
3
+ "version": "1.22.41",
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",