@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
@@ -19,6 +19,7 @@ import { execFileSync, spawnSync } from 'child_process';
19
19
  import * as fs from 'fs';
20
20
  import * as os from 'os';
21
21
  import * as path from 'path';
22
+ import { sleepSync } from '../fs-atomic.js';
22
23
  import { getRuntimeStateDir, getHelpersDir } from '../state.js';
23
24
  import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
24
25
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
@@ -149,6 +150,27 @@ function copyAppBundle(src, dest) {
149
150
  throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
150
151
  }
151
152
  }
153
+ /**
154
+ * Register the freshly-installed bundle with LaunchServices.
155
+ *
156
+ * The helper is copied to ~/Library/Application Support (not /Applications) and
157
+ * launched only via launchd, so LaunchServices may never discover it on its own.
158
+ * A daemon notification posted by the one-shot `MenubarHelper --notify` process is
159
+ * attributed to this bundle, and macOS resolves the notification's LEFT-hand app
160
+ * icon from the bundle's LaunchServices record — so a bundle LS doesn't know about
161
+ * shows a blank app icon there (the right-hand contentImage is unaffected;
162
+ * appIconImage reads the `.icns` directly). `lsregister -f` registers the bundle at
163
+ * its current path so the OS can resolve its AppIcon for that slot. Best-effort:
164
+ * LaunchServices is advisory, and a failure here must never block install.
165
+ */
166
+ function refreshBundleIconRegistration(appPath) {
167
+ const lsregister = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister';
168
+ const bin = fs.existsSync(lsregister) ? lsregister : 'lsregister';
169
+ const r = spawnSync(bin, ['-f', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
170
+ if (r.error) {
171
+ /* lsregister missing / moved — advisory only, ignore. */
172
+ }
173
+ }
152
174
  /** True when the bundle carries a signature the kernel will accept at launch. */
153
175
  export function codesignVerifies(appPath) {
154
176
  const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
@@ -200,6 +222,10 @@ export function ensureMenubarAppInstalled(opts = {}) {
200
222
  }
201
223
  copyAppBundle(src, dest);
202
224
  ensureValidSignature(dest);
225
+ // A fresh copy is exactly when the bundle's icon can be new (first install) or
226
+ // superseded (upgrade) — register it so LaunchServices knows the bundle and can
227
+ // resolve its AppIcon for the left-hand slot of daemon notifications.
228
+ refreshBundleIconRegistration(dest);
203
229
  return installedExecutablePath();
204
230
  }
205
231
  function xmlEscape(s) {
@@ -299,24 +325,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
299
325
  process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
300
326
  return false;
301
327
  }
302
- if (opts.clearOptOut) {
303
- try {
304
- fs.rmSync(disabledSentinelPath(), { force: true });
305
- }
306
- catch { /* already gone */ }
328
+ if (opts.clearOptOut)
329
+ clearMenubarOptOut();
330
+ installAndStartService(exec);
331
+ return true;
332
+ }
333
+ /** Drop the sticky `agents menubar disable` sentinel. */
334
+ function clearMenubarOptOut() {
335
+ try {
336
+ fs.rmSync(disabledSentinelPath(), { force: true });
307
337
  }
338
+ catch { /* already gone */ }
339
+ }
340
+ /**
341
+ * Write the launchd plist for `exec`, restart the job, and stamp the installed
342
+ * version. Shared by `enableMenubarService` and `runMenubarSetup` so the two
343
+ * cannot drift on what "installed and started" means — the version stamp in
344
+ * particular is what the upgrade self-heal reads to decide staleness, and a
345
+ * path that skipped it would make every later `agents` invocation reinstall.
346
+ */
347
+ function installAndStartService(exec) {
308
348
  const plist = servicePlistPath();
309
349
  fs.mkdirSync(path.dirname(plist), { recursive: true });
310
350
  fs.writeFileSync(plist, generateServicePlist(exec));
311
- const uid = process.getuid?.() ?? 0;
312
- restartMenubarLaunchAgent(uid, plist);
313
- // Stamp the version we just installed so the upgrade self-heal can tell when
314
- // a later release ships a newer helper that needs reinstalling.
351
+ restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
315
352
  try {
316
353
  fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
317
354
  }
318
355
  catch { /* best effort */ }
319
- return true;
320
356
  }
321
357
  /**
322
358
  * Pure staleness decision (no I/O) so the truth table is unit-testable. The
@@ -441,6 +477,117 @@ export function installMenubarLaunchAgentOnUpgrade() {
441
477
  /* never block startup on the menu bar */
442
478
  }
443
479
  }
480
+ /**
481
+ * Decide which live helper processes must be ended so exactly one status item
482
+ * survives. Pure so the choice is unit-testable without a live menu bar.
483
+ *
484
+ * EVERY current process is ended, including the wanted one: the caller
485
+ * re-kickstarts the launchd service straight after, so the survivor is the one
486
+ * launchd owns (RunAtLoad + KeepAlive), not whichever copy happened to win a
487
+ * race. Picking a survivor from a `ps` listing cannot do this — the list says
488
+ * nothing about which pid launchd will keep alive, so leaving one alive risks
489
+ * keeping the un-managed copy and re-creating the duplicate on next login.
490
+ */
491
+ export function processesToEnd(status) {
492
+ return [...status.instances, ...status.foreignInstances];
493
+ }
494
+ function endProcess(pid) {
495
+ try {
496
+ process.kill(pid, 'SIGTERM');
497
+ }
498
+ catch { /* already gone */ }
499
+ }
500
+ /**
501
+ * `agents menubar setup` — configure the menu bar end-to-end, idempotently.
502
+ *
503
+ * The one command that gets a machine to the intended state: exactly one status
504
+ * item, owned by a launchd service that starts it at login and restarts it if it
505
+ * dies. Each concern is a reported step, so a partial failure names itself
506
+ * instead of hiding behind "enabled".
507
+ *
508
+ * 1. bundle — install/refresh the .app at the stable App Support path
509
+ * 2. signature — a valid code identity (macOS 26+ SIGKILLs an invalid one)
510
+ * 3. duplicates — end every live helper, so the only survivor is launchd's
511
+ * 4. login item — write the plist (RunAtLoad + KeepAlive) and bootstrap it
512
+ * 5. single — verify exactly one helper came back up
513
+ */
514
+ export function runMenubarSetup() {
515
+ const steps = [];
516
+ const step = (name, outcome, detail) => {
517
+ steps.push({ name, outcome, detail });
518
+ };
519
+ if (!onDarwin()) {
520
+ step('platform', 'failed', `the menu bar helper is macOS only (this is ${process.platform})`);
521
+ return { steps, configured: false, status: getMenubarStatus() };
522
+ }
523
+ const before = getMenubarStatus();
524
+ if (!sourceAppPath()) {
525
+ step('bundle', 'failed', 'no menu-bar helper bundle ships with this install');
526
+ return { steps, configured: false, status: before };
527
+ }
528
+ // 3 before 1: end the running copies BEFORE swapping the bundle underneath
529
+ // them, so no helper keeps a status item alive on a binary that no longer
530
+ // exists on disk.
531
+ const doomed = processesToEnd(before);
532
+ for (const p of doomed)
533
+ endProcess(p.pid);
534
+ if (doomed.length > 1) {
535
+ step('duplicates', 'changed', `ended ${doomed.length} running helpers (${doomed.map((p) => p.pid).join(', ')}) — launchd restarts exactly one`);
536
+ }
537
+ else if (doomed.length === 1) {
538
+ step('duplicates', 'ok', 'one helper was running; restarting it under launchd');
539
+ }
540
+ else {
541
+ step('duplicates', 'ok', 'no helper was running');
542
+ }
543
+ const exec = ensureMenubarAppInstalled({ forceReinstall: true });
544
+ if (!exec) {
545
+ step('bundle', 'failed', 'could not install the helper bundle');
546
+ return { steps, configured: false, status: getMenubarStatus() };
547
+ }
548
+ step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
549
+ if (!codesignVerifies(installedAppPath())) {
550
+ step('signature', 'failed', 'no valid code signature — refusing to start it (launchd KeepAlive would crash-loop)');
551
+ return { steps, configured: false, status: getMenubarStatus() };
552
+ }
553
+ step('signature', 'ok', 'valid');
554
+ // Clear the sticky opt-out: running `setup` is an explicit request for the
555
+ // menu bar, so a stale `menubar disable` must not silently win.
556
+ clearMenubarOptOut();
557
+ installAndStartService(exec);
558
+ step('login item', before.serviceInstalled ? 'ok' : 'changed', `${SERVICE_LABEL} — starts at login, restarts if it dies`);
559
+ // launchd's bootstrap+kickstart is asynchronous; give the status item a beat
560
+ // to claim the lock before counting instances, or `setup` reports zero on a
561
+ // machine that is in fact coming up correctly.
562
+ const after = waitForSingleInstance();
563
+ if (after.instances.length === 1 && after.foreignInstances.length === 0) {
564
+ step('single instance', 'ok', `pid ${after.instances[0].pid}`);
565
+ }
566
+ else if (after.instances.length === 0) {
567
+ step('single instance', 'failed', 'the helper did not come back up — see `agents menubar status`');
568
+ }
569
+ else {
570
+ const extra = [...after.instances.slice(1), ...after.foreignInstances];
571
+ step('single instance', 'failed', `${after.instances.length + after.foreignInstances.length} helpers running (${extra.map((p) => p.pid).join(', ')} are extra)`);
572
+ }
573
+ return {
574
+ steps,
575
+ configured: steps.every((s) => s.outcome !== 'failed'),
576
+ status: after,
577
+ };
578
+ }
579
+ /**
580
+ * Poll (up to ~3s) for launchd to bring the single helper back. Returns the
581
+ * last status read either way — the caller decides what a miss means.
582
+ */
583
+ function waitForSingleInstance() {
584
+ let status = getMenubarStatus();
585
+ for (let i = 0; i < 15 && status.instances.length !== 1; i++) {
586
+ sleepSync(200);
587
+ status = getMenubarStatus();
588
+ }
589
+ return status;
590
+ }
444
591
  /** Parse `ps -axo pid=,<field>=` into pid -> field. The field is the rest of the
445
592
  * line, so a path containing spaces (App Support does) survives intact. */
446
593
  function parsePsLines(psOutput) {
@@ -454,21 +601,28 @@ function parsePsLines(psOutput) {
454
601
  }
455
602
  /**
456
603
  * Split the live MenubarHelper processes into the installed bundle's own
457
- * (`running`) and every other copy (`foreign`).
604
+ * (`own`) and every other copy (`foreign`).
458
605
  *
459
606
  * `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
460
607
  * the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
461
608
  * still reported a healthy `running: yes` — the paste was dead and nothing said
462
609
  * so. A foreign copy is the thing to look for, so name it.
463
610
  *
611
+ * `own` is a LIST, not a boolean: two copies of the INSTALLED bundle can run at
612
+ * once (launchd's KeepAlive service plus a LaunchServices/`open` launch of the
613
+ * same .app), which is the duplicate the user actually sees — two agents marks
614
+ * in the menu bar. Collapsing them to `running: true` reported that state as
615
+ * healthy. The helper now refuses to be the second (SingleInstance.swift), and
616
+ * `agents menubar setup` ends any duplicate a pre-fix helper left behind.
617
+ *
464
618
  * Identity comes from `comm` (the resolved executable), never from a substring
465
619
  * of the command line: matching the latter flags any shell that merely mentions
466
620
  * MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
467
621
  */
468
622
  export function classifyMenubarProcesses(commOutput, commandOutput, installedExec) {
469
623
  const commands = parsePsLines(commandOutput);
624
+ const own = [];
470
625
  const foreign = [];
471
- let running = false;
472
626
  for (const [pid, executable] of parsePsLines(commOutput)) {
473
627
  if (path.basename(executable) !== 'MenubarHelper')
474
628
  continue;
@@ -477,26 +631,26 @@ export function classifyMenubarProcesses(commOutput, commandOutput, installedExe
477
631
  if ((commands.get(pid) || '').includes('--notify'))
478
632
  continue;
479
633
  if (executable === installedExec)
480
- running = true;
634
+ own.push({ pid, executable });
481
635
  else
482
636
  foreign.push({ pid, executable });
483
637
  }
484
- return { running, foreign };
638
+ return { own, foreign };
639
+ }
640
+ /** Live MenubarHelper processes, split by whether they are the installed bundle. */
641
+ function liveMenubarProcesses() {
642
+ if (!onDarwin())
643
+ return { own: [], foreign: [] };
644
+ const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
645
+ const comm = ps('pid=,comm=');
646
+ const command = ps('pid=,command=');
647
+ if (comm.status !== 0 || command.status !== 0)
648
+ return { own: [], foreign: [] };
649
+ return classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
485
650
  }
486
651
  export function getMenubarStatus() {
487
652
  const dest = installedAppPath();
488
- let running = false;
489
- let foreignInstances = [];
490
- if (onDarwin()) {
491
- const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
492
- const comm = ps('pid=,comm=');
493
- const command = ps('pid=,command=');
494
- if (comm.status === 0 && command.status === 0) {
495
- const c = classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
496
- running = c.running;
497
- foreignInstances = c.foreign;
498
- }
499
- }
653
+ const { own, foreign } = liveMenubarProcesses();
500
654
  const serviceInstalled = menubarServiceInstalled();
501
655
  return {
502
656
  platform: process.platform,
@@ -506,8 +660,9 @@ export function getMenubarStatus() {
506
660
  currentVersion: getCliVersion(),
507
661
  stale: onDarwin() && serviceInstalled && menubarSetupStale(),
508
662
  serviceInstalled,
509
- running,
510
- foreignInstances,
663
+ running: own.length > 0,
664
+ instances: own,
665
+ foreignInstances: foreign,
511
666
  disabledByUser: menubarDisabledByUser(),
512
667
  };
513
668
  }
@@ -34,9 +34,11 @@ export declare function killTree(pid: number): void;
34
34
  * sites pass their own `windowsHide` with piped stdio.
35
35
  */
36
36
  export declare function backgroundSpawnOptions(opts?: {
37
+ cwd?: string;
37
38
  fdStdio?: boolean;
38
39
  platform?: NodeJS.Platform;
39
40
  }): {
41
+ cwd: string;
40
42
  detached: boolean;
41
43
  windowsHide: boolean;
42
44
  };
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { execFileSync } from 'child_process';
5
5
  import { readFileSync } from 'fs';
6
+ import * as os from 'os';
6
7
  import { sleepSync } from '../fs-atomic.js';
7
8
  /**
8
9
  * Forcefully terminate a process AND its descendant tree.
@@ -56,12 +57,13 @@ export function killTree(pid) {
56
57
  */
57
58
  export function backgroundSpawnOptions(opts = {}) {
58
59
  const platform = opts.platform ?? process.platform;
60
+ const cwd = opts.cwd ?? os.homedir();
59
61
  if (platform === 'win32') {
60
62
  return opts.fdStdio
61
- ? { detached: true, windowsHide: true }
62
- : { detached: false, windowsHide: true };
63
+ ? { cwd, detached: true, windowsHide: true }
64
+ : { cwd, detached: false, windowsHide: true };
63
65
  }
64
- return { detached: true, windowsHide: false };
66
+ return { cwd, detached: true, windowsHide: false };
65
67
  }
66
68
  /**
67
69
  * Is a process with this PID currently alive?
@@ -17,6 +17,14 @@ export interface ResolvedResource {
17
17
  */
18
18
  source: string;
19
19
  }
20
+ /**
21
+ * True when `rawName` (a filename with its extension already stripped) names a
22
+ * directory doc rather than a resource of `kind`. Exported so every enumerator
23
+ * shares one definition — `listCentralCommands` and `discoverCommands` in
24
+ * `commands.ts` do their own `readdirSync` scans, and without this they would
25
+ * list a `README` that `resolveResource` then refuses to open.
26
+ */
27
+ export declare function isDirectoryDoc(kind: ResourceKind, rawName: string): boolean;
20
28
  /**
21
29
  * Resolve a single resource by kind + name using project > user > system precedence.
22
30
  * For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
@@ -35,6 +35,30 @@ function resourceIsActive(kind, name, source) {
35
35
  const activeKind = profiledKind(kind);
36
36
  return activeKind ? isNameActiveInResourceProfile(activeKind, name, source) : true;
37
37
  }
38
+ /**
39
+ * Documentation filenames that live *beside* resources, describing the directory
40
+ * rather than being a resource in it. A DotAgents repo keeps a `README.md` (for
41
+ * humans) and an `AGENTS.md` (for agents) in each resource dir, with
42
+ * `CLAUDE.md`/`GEMINI.md` symlinked to the latter. Without this filter every one
43
+ * of them materializes as a resource — `commands/README.md` installs a bogus
44
+ * `/README` slash command into every agent home.
45
+ *
46
+ * `rules` is exempt: there `AGENTS.md` IS the resource (the composed ruleset that
47
+ * syncs as each agent's memory file), not documentation about the directory.
48
+ */
49
+ const DOC_BASENAMES = new Set(['readme', 'agents', 'claude', 'gemini']);
50
+ /**
51
+ * True when `rawName` (a filename with its extension already stripped) names a
52
+ * directory doc rather than a resource of `kind`. Exported so every enumerator
53
+ * shares one definition — `listCentralCommands` and `discoverCommands` in
54
+ * `commands.ts` do their own `readdirSync` scans, and without this they would
55
+ * list a `README` that `resolveResource` then refuses to open.
56
+ */
57
+ export function isDirectoryDoc(kind, rawName) {
58
+ if (kind === 'rules')
59
+ return false;
60
+ return DOC_BASENAMES.has(rawName.toLowerCase());
61
+ }
38
62
  /**
39
63
  * Resolve a single resource by kind + name using project > user > system precedence.
40
64
  * For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
@@ -62,7 +86,10 @@ export function resolveResource(kind, name, cwd) {
62
86
  }
63
87
  continue;
64
88
  }
65
- // Try with common file extensions
89
+ // Try with common file extensions. A directory doc (README/AGENTS/CLAUDE/
90
+ // GEMINI) describes the directory and is never itself a resource.
91
+ if (isDirectoryDoc(kind, name))
92
+ continue;
66
93
  for (const ext of ['.md', '.yaml', '.yml']) {
67
94
  const withExt = exactPath + ext;
68
95
  if (fs.existsSync(withExt)) {
@@ -105,6 +132,12 @@ export function listResources(kind, cwd) {
105
132
  if (entry.name.startsWith('.'))
106
133
  continue;
107
134
  const rawName = entry.name.replace(/\.(md|yaml|yml)$/, '');
135
+ // Not isFile(): a Dirent for a symlink reports isFile() === false, and
136
+ // CLAUDE.md/GEMINI.md are symlinks to AGENTS.md by convention. Anything
137
+ // that is not a directory is a candidate doc; a resource directory that
138
+ // happens to be named `agents/` is still a real resource.
139
+ if (!entry.isDirectory() && isDirectoryDoc(kind, rawName))
140
+ continue;
108
141
  if (seen.has(rawName))
109
142
  continue;
110
143
  if (!resourceIsActive(kind, rawName, source))
@@ -10,6 +10,7 @@
10
10
  * pin for fleet/host/cloud strategies (applied at add/sync time).
11
11
  */
12
12
  import type { JobConfig } from './routines.js';
13
+ import { type DevicePlatform } from './devices/registry.js';
13
14
  export type PlacementTarget = {
14
15
  mode: 'local';
15
16
  } | {
@@ -30,7 +31,7 @@ export type PlacementTarget = {
30
31
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
31
32
  * always-local. Control / offline / no-address devices are never chosen.
32
33
  */
33
- export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>): string | null;
34
+ export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>, platform?: DevicePlatform): string | null;
34
35
  /**
35
36
  * Resolve where a fired job's body should execute.
36
37
  * Throws a human-readable Error when placement cannot be satisfied.
@@ -25,7 +25,7 @@ import { planFleetTargets } from './devices/fleet.js';
25
25
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
26
26
  * always-local. Control / offline / no-address devices are never chosen.
27
27
  */
28
- export function pickFleetDevice(_config) {
28
+ export function pickFleetDevice(_config, platform) {
29
29
  let reg;
30
30
  try {
31
31
  reg = loadDevicesSync();
@@ -34,11 +34,15 @@ export function pickFleetDevice(_config) {
34
34
  return null;
35
35
  }
36
36
  const planned = planFleetTargets(reg);
37
- const candidates = planned.filter((t) => !t.skip).map((t) => t.device.name);
37
+ const candidates = planned
38
+ .filter((t) => !t.skip && (!platform || t.device.platform === platform))
39
+ .map((t) => t.device.name);
38
40
  if (candidates.length === 0) {
39
41
  // No registry / nothing online: fall back to self so a single-box fleet
40
- // without a registry entry still runs locally.
41
- return machineId();
42
+ // without a registry entry still runs locally. Only when no platform filter
43
+ // was requested — an unmet filter must fail loud so e.g. `fleet/linux` never
44
+ // silently lands on a macOS box.
45
+ return platform ? null : machineId();
42
46
  }
43
47
  const self = machineId();
44
48
  const selfMatch = candidates.find((n) => normalizeHost(n) === self);
@@ -78,6 +78,10 @@ export interface LinearJobTrigger {
78
78
  teamKey?: string;
79
79
  /** Required issue label name. */
80
80
  label?: string;
81
+ /** Current Linear state name that must match (e.g. `Plan`). */
82
+ stateTo?: string;
83
+ /** Previous Linear state name that must match (e.g. `Triage`). */
84
+ stateFrom?: string;
81
85
  }
82
86
  export type JobTrigger = GithubJobTrigger | LinearJobTrigger;
83
87
  /**
@@ -118,6 +122,12 @@ export interface JobConfig {
118
122
  * overdue; everywhere else it is inert and `run` refuses with a pointer.
119
123
  */
120
124
  devices?: string[];
125
+ /**
126
+ * Environment variables injected into the spawned run, on top of the sandbox
127
+ * overlay's own. Merged by `buildSpawnEnv`, so it applies to both the
128
+ * foreground and detached execution paths.
129
+ */
130
+ env?: Record<string, string>;
121
131
  /**
122
132
  * Execution placement — run the job body on this machine over SSH (a
123
133
  * registered host, device, capability tag, or user@host) instead of locally.
@@ -360,8 +370,54 @@ export declare function oneShotScheduleFireDate(schedule: string | undefined | n
360
370
  export declare function isPastOneShotRoutine(config: Pick<JobConfig, 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
361
371
  export declare function hasCompletedOneShotRun(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
362
372
  export declare function shouldPurgeCompletedOneShotRoutine(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
373
+ /**
374
+ * Context passed to `resolveJobPrompt` when a job is fired by a webhook. Lets
375
+ * prompts use `{{issue.identifier}}`, `{{updatedFrom.state.name}}`, etc.
376
+ */
377
+ export interface WebhookContext {
378
+ source: string;
379
+ event: string;
380
+ action?: string;
381
+ issue?: unknown;
382
+ updatedFrom?: unknown;
383
+ pull_request?: unknown;
384
+ repository?: unknown;
385
+ }
386
+ /**
387
+ * Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
388
+ * Missing values are replaced with an empty string.
389
+ */
390
+ export declare function substituteWebhookPrompt(prompt: string, context: WebhookContext): string;
391
+ /**
392
+ * Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
393
+ * quoting every substituted value so payload content cannot break out of it.
394
+ *
395
+ * `run.command` is executed through a shell, and its context is built from an
396
+ * external webhook payload — `issue.title`, `issue.description`, and the GitHub
397
+ * `pull_request` fields are free text any outside contributor can set. Pasting
398
+ * those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
399
+ * turns an operator's `echo {{issue.title}}` into a command-injection sink.
400
+ *
401
+ * The template itself is operator-authored and stays unquoted, so pipes,
402
+ * redirects, and `&&` in the configured command keep working. Only the
403
+ * interpolated values are quoted.
404
+ *
405
+ * POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
406
+ * embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
407
+ * honour these rules — see `assertShellSubstitutionSupported`.
408
+ */
409
+ export declare function substituteWebhookCommand(command: string, context: WebhookContext): string;
410
+ /**
411
+ * Refuse a `run.command` carrying placeholders on a platform whose shell we
412
+ * cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
413
+ * Windows, where POSIX single-quoting is not a quoting mechanism at all, so
414
+ * {@link substituteWebhookCommand} would not contain a hostile value.
415
+ *
416
+ * Fail loud rather than execute something we cannot prove is safe.
417
+ */
418
+ export declare function assertShellSubstitutionSupported(command: string, platform?: NodeJS.Platform): void;
363
419
  /** Expand built-in and user-defined template variables in a job's prompt string. */
364
- export declare function resolveJobPrompt(config: JobConfig): string;
420
+ export declare function resolveJobPrompt(config: JobConfig, context?: WebhookContext): string;
365
421
  /** Parse a human-readable timeout string (e.g. "10m", "2h", "1h30m", "3d", "1w") into milliseconds.
366
422
  * Accepts combinations of w (weeks), d (days), h (hours), m (minutes).
367
423
  * Returns null if the string is empty, matches nothing, totals zero, or exceeds 1 week.
@@ -569,6 +569,12 @@ export function validateTrigger(trigger) {
569
569
  if (linear.label !== undefined && typeof linear.label !== 'string') {
570
570
  errors.push('trigger.label must be a string');
571
571
  }
572
+ if (linear.stateTo !== undefined && typeof linear.stateTo !== 'string') {
573
+ errors.push('trigger.stateTo must be a string');
574
+ }
575
+ if (linear.stateFrom !== undefined && typeof linear.stateFrom !== 'string') {
576
+ errors.push('trigger.stateFrom must be a string');
577
+ }
572
578
  return errors;
573
579
  }
574
580
  function isParseableDate(value) {
@@ -702,8 +708,71 @@ export function hasCompletedOneShotRun(config, now = new Date()) {
702
708
  export function shouldPurgeCompletedOneShotRoutine(config, now = new Date()) {
703
709
  return hasCompletedOneShotRun(config, now);
704
710
  }
711
+ function getPath(obj, path) {
712
+ const parts = path.split('.');
713
+ let current = obj;
714
+ for (const part of parts) {
715
+ if (current === null || current === undefined)
716
+ return undefined;
717
+ current = current[part];
718
+ }
719
+ return current;
720
+ }
721
+ /**
722
+ * Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
723
+ * Missing values are replaced with an empty string.
724
+ */
725
+ export function substituteWebhookPrompt(prompt, context) {
726
+ return prompt.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
727
+ const value = getPath(context, rawPath.trim());
728
+ if (value === undefined || value === null)
729
+ return '';
730
+ return String(value);
731
+ });
732
+ }
733
+ /**
734
+ * Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
735
+ * quoting every substituted value so payload content cannot break out of it.
736
+ *
737
+ * `run.command` is executed through a shell, and its context is built from an
738
+ * external webhook payload — `issue.title`, `issue.description`, and the GitHub
739
+ * `pull_request` fields are free text any outside contributor can set. Pasting
740
+ * those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
741
+ * turns an operator's `echo {{issue.title}}` into a command-injection sink.
742
+ *
743
+ * The template itself is operator-authored and stays unquoted, so pipes,
744
+ * redirects, and `&&` in the configured command keep working. Only the
745
+ * interpolated values are quoted.
746
+ *
747
+ * POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
748
+ * embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
749
+ * honour these rules — see `assertShellSubstitutionSupported`.
750
+ */
751
+ export function substituteWebhookCommand(command, context) {
752
+ return command.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
753
+ const value = getPath(context, rawPath.trim());
754
+ if (value === undefined || value === null)
755
+ return "''";
756
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
757
+ });
758
+ }
759
+ /**
760
+ * Refuse a `run.command` carrying placeholders on a platform whose shell we
761
+ * cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
762
+ * Windows, where POSIX single-quoting is not a quoting mechanism at all, so
763
+ * {@link substituteWebhookCommand} would not contain a hostile value.
764
+ *
765
+ * Fail loud rather than execute something we cannot prove is safe.
766
+ */
767
+ export function assertShellSubstitutionSupported(command, platform = process.platform) {
768
+ if (platform === 'win32' && /\{\{[^{}]+\}\}/.test(command)) {
769
+ throw new Error('run.command with {{…}} placeholders is not supported on Windows: the values come from an ' +
770
+ 'untrusted webhook payload and cmd.exe cannot be quoted safely. Use run.prompt, or a ' +
771
+ 'command with no placeholders.');
772
+ }
773
+ }
705
774
  /** Expand built-in and user-defined template variables in a job's prompt string. */
706
- export function resolveJobPrompt(config) {
775
+ export function resolveJobPrompt(config, context) {
707
776
  const now = new Date();
708
777
  const tz = config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
709
778
  const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -725,6 +794,10 @@ export function resolveJobPrompt(config) {
725
794
  prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
726
795
  }
727
796
  }
797
+ // Webhook-driven variables ({{issue.identifier}}, {{updatedFrom.state.name}}, ...)
798
+ if (context) {
799
+ prompt = substituteWebhookPrompt(prompt, context);
800
+ }
728
801
  // Last report (special handling). Only a COMPLETED run's report is injected —
729
802
  // a failed run's report.md is the agent's error text (e.g. a login prompt on
730
803
  // an auth failure), and feeding that into the next prompt poisons every
@@ -26,6 +26,8 @@ export interface RunResult {
26
26
  /** Agents the daemon can actually run, derived from the command table above
27
27
  * so the `--agent` help and any validation can never drift from it. */
28
28
  export declare const ROUTINE_AGENT_IDS: readonly string[];
29
+ /** Stable working directory for routine children, independent of the daemon's launch cwd. */
30
+ export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
29
31
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
30
32
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
31
33
  export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;