@phnx-labs/agents-cli 1.20.61 → 1.20.63

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 (89) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +51 -1
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +100 -28
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/commands.js +29 -0
  32. package/dist/lib/convert.d.ts +11 -0
  33. package/dist/lib/convert.js +22 -0
  34. package/dist/lib/daemon.js +2 -0
  35. package/dist/lib/devices/fleet.d.ts +62 -0
  36. package/dist/lib/devices/fleet.js +128 -0
  37. package/dist/lib/doctor-diff.js +17 -1
  38. package/dist/lib/funnel.d.ts +5 -0
  39. package/dist/lib/funnel.js +23 -0
  40. package/dist/lib/git.d.ts +21 -5
  41. package/dist/lib/git.js +64 -14
  42. package/dist/lib/goose-commands.d.ts +41 -0
  43. package/dist/lib/goose-commands.js +176 -0
  44. package/dist/lib/hooks.js +134 -0
  45. package/dist/lib/hosts/credentials.d.ts +28 -0
  46. package/dist/lib/hosts/credentials.js +48 -0
  47. package/dist/lib/hosts/dispatch.d.ts +25 -0
  48. package/dist/lib/hosts/dispatch.js +68 -2
  49. package/dist/lib/hosts/passthrough.d.ts +13 -10
  50. package/dist/lib/hosts/passthrough.js +119 -29
  51. package/dist/lib/mailbox-gc.js +4 -16
  52. package/dist/lib/migrate.d.ts +12 -0
  53. package/dist/lib/migrate.js +55 -1
  54. package/dist/lib/permissions.d.ts +15 -0
  55. package/dist/lib/permissions.js +99 -0
  56. package/dist/lib/plugins.d.ts +20 -0
  57. package/dist/lib/plugins.js +118 -0
  58. package/dist/lib/resources/permissions.js +2 -0
  59. package/dist/lib/routines.d.ts +29 -10
  60. package/dist/lib/routines.js +47 -15
  61. package/dist/lib/session/active.d.ts +8 -1
  62. package/dist/lib/session/active.js +1 -0
  63. package/dist/lib/session/parse.js +12 -0
  64. package/dist/lib/session/state.d.ts +33 -0
  65. package/dist/lib/session/state.js +40 -0
  66. package/dist/lib/session/sync/agents.d.ts +2 -0
  67. package/dist/lib/session/sync/agents.js +39 -1
  68. package/dist/lib/session/sync/config.d.ts +8 -0
  69. package/dist/lib/session/sync/config.js +6 -1
  70. package/dist/lib/session/sync/provision.d.ts +49 -0
  71. package/dist/lib/session/sync/provision.js +91 -0
  72. package/dist/lib/session/sync/sync.d.ts +3 -0
  73. package/dist/lib/session/sync/sync.js +26 -6
  74. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  75. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  76. package/dist/lib/staleness/detectors/permissions.js +23 -0
  77. package/dist/lib/staleness/detectors/subagents.js +36 -0
  78. package/dist/lib/staleness/writers/commands.js +7 -0
  79. package/dist/lib/staleness/writers/hooks.js +1 -1
  80. package/dist/lib/staleness/writers/subagents.js +22 -3
  81. package/dist/lib/startup/command-registry.d.ts +2 -0
  82. package/dist/lib/startup/command-registry.js +11 -0
  83. package/dist/lib/state.d.ts +10 -2
  84. package/dist/lib/state.js +14 -2
  85. package/dist/lib/subagents.d.ts +32 -0
  86. package/dist/lib/subagents.js +238 -0
  87. package/dist/lib/triggers/webhook.d.ts +70 -27
  88. package/dist/lib/triggers/webhook.js +264 -43
  89. package/package.json +1 -1
@@ -66,6 +66,8 @@ export const loadMessage = async () => (await import('../../commands/message.js'
66
66
  export const loadFeed = async () => (await import('../../commands/feed.js')).registerFeedCommand;
67
67
  export const loadServe = async () => (await import('../../commands/serve.js')).registerServeCommand;
68
68
  export const loadAudit = async () => (await import('../../commands/audit.js')).registerAuditCommands;
69
+ export const loadWebhook = async () => (await import('../../commands/webhook.js')).registerWebhookCommand;
70
+ export const loadFunnel = async () => (await import('../../commands/funnel.js')).registerFunnelCommand;
69
71
  /**
70
72
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
71
73
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -151,8 +153,15 @@ export const COMMAND_LOADERS = {
151
153
  events: [loadEvents],
152
154
  ssh: [loadSsh],
153
155
  devices: [loadSsh],
156
+ // `fleet` is a commander alias of `devices` (see commands/ssh.ts); list it so
157
+ // lazy registration loads the devices tree when the user types `agents fleet`.
158
+ fleet: [loadSsh],
154
159
  pull: [loadPull],
155
160
  push: [loadPush],
161
+ // `repos` is the canonical command name; `repo` remains a convenience alias
162
+ // (see commands/repo.ts). List both so lazy registration loads the tree
163
+ // whichever the user types.
164
+ repos: [loadRepo],
156
165
  repo: [loadRepo],
157
166
  setup: [loadSetup],
158
167
  sessions: [loadSessions],
@@ -162,4 +171,6 @@ export const COMMAND_LOADERS = {
162
171
  feed: [loadFeed],
163
172
  serve: [loadServe],
164
173
  audit: [loadAudit],
174
+ webhook: [loadWebhook],
175
+ funnel: [loadFunnel],
165
176
  };
@@ -109,13 +109,21 @@ export declare function getCacheDir(): string;
109
109
  export declare function getPackagesDir(): string;
110
110
  /** Path to routine YAML definitions (~/.agents/routines/). */
111
111
  export declare function getRoutinesDir(): string;
112
+ /**
113
+ * Path to built-in routine definitions shipped in the system repo
114
+ * (`~/.agents/.system/routines/`). Unioned under the user routines dir by
115
+ * listJobs()/readJob(): a routine shipped here fires for every install, and a
116
+ * user routine of the same name overrides it (a user copy with `enabled: false`
117
+ * disables the built-in). The daemon fires these; the directory need not exist.
118
+ */
119
+ export declare function getSystemRoutinesDir(): string;
112
120
  /**
113
121
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
114
122
  * or null when no project `.agents/` is found by walking up from cwd.
115
123
  *
116
124
  * Project routines participate in `list`/`view`/`run` for inspection but are
117
- * NOT fired by the daemon (which runs from $HOME and only loads user routines).
118
- * Opt-in firing for project routines is tracked as a follow-up.
125
+ * NOT fired by the daemon (which runs from $HOME and loads only user + system
126
+ * routines). Opt-in firing for project routines is tracked as a follow-up.
119
127
  */
120
128
  export declare function getProjectRoutinesDir(cwd?: string): string | null;
121
129
  /** Path to routine execution logs (~/.agents/.history/runs/). */
package/dist/lib/state.js CHANGED
@@ -69,6 +69,10 @@ const SYSTEM_PERMISSIONS_DIR = path.join(SYSTEM_AGENTS_DIR, 'permissions');
69
69
  const SYSTEM_SUBAGENTS_DIR = path.join(SYSTEM_AGENTS_DIR, 'subagents');
70
70
  const SYSTEM_WORKFLOWS_DIR = path.join(SYSTEM_AGENTS_DIR, 'workflows');
71
71
  const SYSTEM_PLUGINS_DIR = path.join(SYSTEM_AGENTS_DIR, 'plugins');
72
+ // Built-in routines shipped in the system repo (gh:phnx-labs/.agents-system).
73
+ // Unioned under user routines by listJobs()/readJob() so a routine shipped here
74
+ // fires for every install, while a user routine of the same name overrides it.
75
+ const SYSTEM_ROUTINES_DIR = path.join(SYSTEM_AGENTS_DIR, 'routines');
72
76
  const SYSTEM_PROMPTCUTS_FILE = path.join(SYSTEM_AGENTS_DIR, 'hooks', 'promptcuts.yaml');
73
77
  const SYSTEM_MCP_CONFIG_FILE = path.join(SYSTEM_AGENTS_DIR, 'mcp.json');
74
78
  const SYSTEM_INSTRUCTIONS_FILE = path.join(SYSTEM_AGENTS_DIR, 'instructions.md');
@@ -296,13 +300,21 @@ export function getCacheDir() { return CACHE_DIR; }
296
300
  export function getPackagesDir() { return PACKAGES_DIR; }
297
301
  /** Path to routine YAML definitions (~/.agents/routines/). */
298
302
  export function getRoutinesDir() { return ROUTINES_DIR; }
303
+ /**
304
+ * Path to built-in routine definitions shipped in the system repo
305
+ * (`~/.agents/.system/routines/`). Unioned under the user routines dir by
306
+ * listJobs()/readJob(): a routine shipped here fires for every install, and a
307
+ * user routine of the same name overrides it (a user copy with `enabled: false`
308
+ * disables the built-in). The daemon fires these; the directory need not exist.
309
+ */
310
+ export function getSystemRoutinesDir() { return SYSTEM_ROUTINES_DIR; }
299
311
  /**
300
312
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
301
313
  * or null when no project `.agents/` is found by walking up from cwd.
302
314
  *
303
315
  * Project routines participate in `list`/`view`/`run` for inspection but are
304
- * NOT fired by the daemon (which runs from $HOME and only loads user routines).
305
- * Opt-in firing for project routines is tracked as a follow-up.
316
+ * NOT fired by the daemon (which runs from $HOME and loads only user + system
317
+ * routines). Opt-in firing for project routines is tracked as a follow-up.
306
318
  */
307
319
  export function getProjectRoutinesDir(cwd = process.cwd()) {
308
320
  const projectAgentsDir = getProjectAgentsDir(cwd);
@@ -68,6 +68,27 @@ export declare function transformSubagentForDroid(subagentDir: string): string;
68
68
  * See GitHub docs for custom agents.
69
69
  */
70
70
  export declare const transformSubagentForCopilot: typeof transformSubagentForDroid;
71
+ /**
72
+ * Transform a subagent into a Cursor CLI custom subagent `.md` file.
73
+ *
74
+ * Cursor loads subagents from `.cursor/agents/*.md` (project) or
75
+ * `~/.cursor/agents/*.md` (user) — Markdown with YAML frontmatter
76
+ * (name, description, model; also readonly/is_background, which our
77
+ * frontmatter schema doesn't carry). Cursor has no `color` field, so this is
78
+ * an alias of transformSubagentForDroid, same as Copilot.
79
+ * See https://cursor.com/docs/subagents.
80
+ */
81
+ export declare const transformSubagentForCursor: typeof transformSubagentForDroid;
82
+ /**
83
+ * Transform a subagent into a ForgeCode custom subagent `.md` file.
84
+ *
85
+ * ForgeCode loads named agents from `.forge/agents/*.md` (project) or
86
+ * `~/.forge/agents/*.md` (user) — Markdown with YAML frontmatter (id, title,
87
+ * description, tools, model, temperature) + a system-prompt body. ForgeCode has
88
+ * no `color` field, so this is an alias of transformSubagentForDroid, same as
89
+ * Copilot/Cursor. See https://forgecode.dev/docs/agent-definition-guide/.
90
+ */
91
+ export declare const transformSubagentForForge: typeof transformSubagentForDroid;
71
92
  /**
72
93
  * Transform a subagent into Antigravity's custom-agent markdown shape.
73
94
  *
@@ -138,6 +159,17 @@ export declare function transformSubagentForCodex(subagentDir: string): string;
138
159
  * set so the subagent can actually run.
139
160
  */
140
161
  export declare function transformSubagentForKiro(subagentDir: string): string;
162
+ /**
163
+ * Transform a subagent into a Goose recipe YAML file.
164
+ *
165
+ * Goose has no dedicated subagent file format — a named subagent IS a recipe.
166
+ * Goose resolves named agents from `~/.config/goose/agents/<name>.yaml` (global)
167
+ * and delegates to them by name in autonomous mode. The recipe schema mirrors the
168
+ * one agents-cli already emits for Goose workflow recipes (`writeGooseSubrecipe`):
169
+ * `version`, `title`, `description`, `instructions`, `prompt`, plus an optional
170
+ * `settings.goose_model`. See goose-docs.ai context-engineering/subagents.
171
+ */
172
+ export declare function transformSubagentForGoose(subagentDir: string): string;
141
173
  /**
142
174
  * Sync a subagent to an OpenClaw workspace
143
175
  * Copies full directory, renames AGENT.md to AGENTS.md
@@ -274,6 +274,27 @@ export function transformSubagentForDroid(subagentDir) {
274
274
  * See GitHub docs for custom agents.
275
275
  */
276
276
  export const transformSubagentForCopilot = transformSubagentForDroid;
277
+ /**
278
+ * Transform a subagent into a Cursor CLI custom subagent `.md` file.
279
+ *
280
+ * Cursor loads subagents from `.cursor/agents/*.md` (project) or
281
+ * `~/.cursor/agents/*.md` (user) — Markdown with YAML frontmatter
282
+ * (name, description, model; also readonly/is_background, which our
283
+ * frontmatter schema doesn't carry). Cursor has no `color` field, so this is
284
+ * an alias of transformSubagentForDroid, same as Copilot.
285
+ * See https://cursor.com/docs/subagents.
286
+ */
287
+ export const transformSubagentForCursor = transformSubagentForDroid;
288
+ /**
289
+ * Transform a subagent into a ForgeCode custom subagent `.md` file.
290
+ *
291
+ * ForgeCode loads named agents from `.forge/agents/*.md` (project) or
292
+ * `~/.forge/agents/*.md` (user) — Markdown with YAML frontmatter (id, title,
293
+ * description, tools, model, temperature) + a system-prompt body. ForgeCode has
294
+ * no `color` field, so this is an alias of transformSubagentForDroid, same as
295
+ * Copilot/Cursor. See https://forgecode.dev/docs/agent-definition-guide/.
296
+ */
297
+ export const transformSubagentForForge = transformSubagentForDroid;
277
298
  /**
278
299
  * Transform a subagent into Antigravity's custom-agent markdown shape.
279
300
  *
@@ -493,6 +514,45 @@ export function transformSubagentForKiro(subagentDir) {
493
514
  }
494
515
  return JSON.stringify(config, null, 2);
495
516
  }
517
+ /**
518
+ * Transform a subagent into a Goose recipe YAML file.
519
+ *
520
+ * Goose has no dedicated subagent file format — a named subagent IS a recipe.
521
+ * Goose resolves named agents from `~/.config/goose/agents/<name>.yaml` (global)
522
+ * and delegates to them by name in autonomous mode. The recipe schema mirrors the
523
+ * one agents-cli already emits for Goose workflow recipes (`writeGooseSubrecipe`):
524
+ * `version`, `title`, `description`, `instructions`, `prompt`, plus an optional
525
+ * `settings.goose_model`. See goose-docs.ai context-engineering/subagents.
526
+ */
527
+ export function transformSubagentForGoose(subagentDir) {
528
+ const agentMd = path.join(subagentDir, 'AGENT.md');
529
+ const frontmatter = parseSubagentFrontmatter(agentMd);
530
+ const body = getSubagentBody(agentMd);
531
+ if (!frontmatter) {
532
+ throw new Error(`Invalid AGENT.md in ${subagentDir}`);
533
+ }
534
+ const files = fs.readdirSync(subagentDir)
535
+ .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
536
+ .sort();
537
+ let prompt = body || frontmatter.description || frontmatter.name;
538
+ for (const file of files) {
539
+ const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
540
+ const sectionName = file.replace('.md', '');
541
+ const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
542
+ prompt += `\n\n## ${title}\n\n${content}`;
543
+ }
544
+ const recipe = {
545
+ version: '1.0.0',
546
+ title: frontmatter.name || path.basename(subagentDir),
547
+ description: frontmatter.description || frontmatter.name || path.basename(subagentDir),
548
+ instructions: prompt,
549
+ prompt,
550
+ };
551
+ if (frontmatter.model) {
552
+ recipe.settings = { goose_model: frontmatter.model };
553
+ }
554
+ return yaml.stringify(recipe);
555
+ }
496
556
  /**
497
557
  * Sync a subagent to an OpenClaw workspace
498
558
  * Copies full directory, renames AGENT.md to AGENTS.md
@@ -609,6 +669,51 @@ export function installSubagentToAgent(subagentDir, subagentName, agent, agentHo
609
669
  return { success: false, error: String(err) };
610
670
  }
611
671
  }
672
+ else if (agent === 'cursor') {
673
+ // Cursor: flattened .md custom subagent under ~/.cursor/agents/
674
+ const agentsDir = path.join(agentHome, '.cursor', 'agents');
675
+ if (!fs.existsSync(agentsDir)) {
676
+ fs.mkdirSync(agentsDir, { recursive: true });
677
+ }
678
+ try {
679
+ const transformed = transformSubagentForCursor(subagentDir);
680
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
681
+ return { success: true };
682
+ }
683
+ catch (err) {
684
+ return { success: false, error: String(err) };
685
+ }
686
+ }
687
+ else if (agent === 'forge') {
688
+ // ForgeCode: flattened .md custom subagent under ~/.forge/agents/
689
+ const agentsDir = path.join(agentHome, '.forge', 'agents');
690
+ if (!fs.existsSync(agentsDir)) {
691
+ fs.mkdirSync(agentsDir, { recursive: true });
692
+ }
693
+ try {
694
+ const transformed = transformSubagentForForge(subagentDir);
695
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
696
+ return { success: true };
697
+ }
698
+ catch (err) {
699
+ return { success: false, error: String(err) };
700
+ }
701
+ }
702
+ else if (agent === 'goose') {
703
+ // Goose: recipe YAML custom-agent file under ~/.config/goose/agents/
704
+ const agentsDir = path.join(agentHome, '.config', 'goose', 'agents');
705
+ if (!fs.existsSync(agentsDir)) {
706
+ fs.mkdirSync(agentsDir, { recursive: true });
707
+ }
708
+ try {
709
+ const transformed = transformSubagentForGoose(subagentDir);
710
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.yaml`), transformed);
711
+ return { success: true };
712
+ }
713
+ catch (err) {
714
+ return { success: false, error: String(err) };
715
+ }
716
+ }
612
717
  else {
613
718
  // Other agents don't support subagents yet
614
719
  return { success: false, error: `Agent '${agent}' does not support subagents` };
@@ -670,6 +775,27 @@ export function removeSubagentFromAgent(subagentName, agent, agentHome) {
670
775
  }
671
776
  return { success: true };
672
777
  }
778
+ else if (agent === 'cursor') {
779
+ const targetPath = safeJoin(path.join(agentHome, '.cursor', 'agents'), `${subagentName}.md`);
780
+ if (fs.existsSync(targetPath)) {
781
+ fs.unlinkSync(targetPath);
782
+ }
783
+ return { success: true };
784
+ }
785
+ else if (agent === 'forge') {
786
+ const targetPath = safeJoin(path.join(agentHome, '.forge', 'agents'), `${subagentName}.md`);
787
+ if (fs.existsSync(targetPath)) {
788
+ fs.unlinkSync(targetPath);
789
+ }
790
+ return { success: true };
791
+ }
792
+ else if (agent === 'goose') {
793
+ const targetPath = safeJoin(path.join(agentHome, '.config', 'goose', 'agents'), `${subagentName}.yaml`);
794
+ if (fs.existsSync(targetPath)) {
795
+ fs.unlinkSync(targetPath);
796
+ }
797
+ return { success: true };
798
+ }
673
799
  else {
674
800
  return { success: true }; // No-op for unsupported agents
675
801
  }
@@ -886,6 +1012,61 @@ export function listSubagentsForAgent(agentId, home) {
886
1012
  });
887
1013
  }
888
1014
  }
1015
+ else if (agentId === 'cursor') {
1016
+ // Cursor: flat `<name>.md` files under ~/.cursor/agents/
1017
+ const agentsDir = path.join(home, '.cursor', 'agents');
1018
+ if (!fs.existsSync(agentsDir))
1019
+ return subagents;
1020
+ for (const file of fs.readdirSync(agentsDir)) {
1021
+ if (!file.endsWith('.md'))
1022
+ continue;
1023
+ const filePath = path.join(agentsDir, file);
1024
+ if (!fs.statSync(filePath).isFile())
1025
+ continue;
1026
+ const name = file.replace(/\.md$/, '');
1027
+ const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
1028
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1029
+ }
1030
+ }
1031
+ else if (agentId === 'forge') {
1032
+ // ForgeCode: flat `<name>.md` files under ~/.forge/agents/
1033
+ const agentsDir = path.join(home, '.forge', 'agents');
1034
+ if (!fs.existsSync(agentsDir))
1035
+ return subagents;
1036
+ for (const file of fs.readdirSync(agentsDir)) {
1037
+ if (!file.endsWith('.md'))
1038
+ continue;
1039
+ const filePath = path.join(agentsDir, file);
1040
+ if (!fs.statSync(filePath).isFile())
1041
+ continue;
1042
+ const name = file.replace(/\.md$/, '');
1043
+ const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
1044
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1045
+ }
1046
+ }
1047
+ else if (agentId === 'goose') {
1048
+ // Goose: recipe YAML files under ~/.config/goose/agents/
1049
+ const agentsDir = path.join(home, '.config', 'goose', 'agents');
1050
+ if (!fs.existsSync(agentsDir))
1051
+ return subagents;
1052
+ for (const file of fs.readdirSync(agentsDir)) {
1053
+ if (!file.endsWith('.yaml'))
1054
+ continue;
1055
+ const filePath = path.join(agentsDir, file);
1056
+ if (!fs.statSync(filePath).isFile())
1057
+ continue;
1058
+ const name = file.replace(/\.yaml$/, '');
1059
+ let frontmatter;
1060
+ try {
1061
+ const recipe = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
1062
+ frontmatter = { name: recipe?.title || name, description: recipe?.description || '' };
1063
+ }
1064
+ catch {
1065
+ continue;
1066
+ }
1067
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1068
+ }
1069
+ }
889
1070
  return subagents;
890
1071
  }
891
1072
  /**
@@ -984,6 +1165,42 @@ export function diffVersionSubagents(agent, version) {
984
1165
  }
985
1166
  }
986
1167
  }
1168
+ else if (agent === 'cursor') {
1169
+ const agentsDir = path.join(versionHome, '.cursor', 'agents');
1170
+ if (fs.existsSync(agentsDir)) {
1171
+ for (const file of fs.readdirSync(agentsDir)) {
1172
+ if (!file.endsWith('.md'))
1173
+ continue;
1174
+ const name = path.basename(file, '.md');
1175
+ if (!discovered.has(name))
1176
+ orphans.push(name);
1177
+ }
1178
+ }
1179
+ }
1180
+ else if (agent === 'forge') {
1181
+ const agentsDir = path.join(versionHome, '.forge', 'agents');
1182
+ if (fs.existsSync(agentsDir)) {
1183
+ for (const file of fs.readdirSync(agentsDir)) {
1184
+ if (!file.endsWith('.md'))
1185
+ continue;
1186
+ const name = path.basename(file, '.md');
1187
+ if (!discovered.has(name))
1188
+ orphans.push(name);
1189
+ }
1190
+ }
1191
+ }
1192
+ else if (agent === 'goose') {
1193
+ const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
1194
+ if (fs.existsSync(agentsDir)) {
1195
+ for (const file of fs.readdirSync(agentsDir)) {
1196
+ if (!file.endsWith('.yaml'))
1197
+ continue;
1198
+ const name = path.basename(file, '.yaml');
1199
+ if (!discovered.has(name))
1200
+ orphans.push(name);
1201
+ }
1202
+ }
1203
+ }
987
1204
  return { agent, version, orphans: orphans.sort() };
988
1205
  }
989
1206
  /**
@@ -1071,6 +1288,27 @@ export function removeSubagentFromVersion(agent, version, subagentName) {
1071
1288
  fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.json.${stamp}`));
1072
1289
  }
1073
1290
  }
1291
+ else if (agent === 'cursor') {
1292
+ const targetPath = path.join(versionHome, '.cursor', 'agents', `${subagentName}.md`);
1293
+ if (fs.existsSync(targetPath)) {
1294
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1295
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
1296
+ }
1297
+ }
1298
+ else if (agent === 'forge') {
1299
+ const targetPath = path.join(versionHome, '.forge', 'agents', `${subagentName}.md`);
1300
+ if (fs.existsSync(targetPath)) {
1301
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1302
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
1303
+ }
1304
+ }
1305
+ else if (agent === 'goose') {
1306
+ const targetPath = path.join(versionHome, '.config', 'goose', 'agents', `${subagentName}.yaml`);
1307
+ if (fs.existsSync(targetPath)) {
1308
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1309
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.yaml.${stamp}`));
1310
+ }
1311
+ }
1074
1312
  return { success: true };
1075
1313
  }
1076
1314
  catch (err) {
@@ -1,29 +1,34 @@
1
1
  /**
2
- * GitHub webhook trigger receiver for routines.
2
+ * Public webhook trigger receiver for routines.
3
3
  *
4
4
  * A routine may declare a `trigger` block instead of (or alongside) a cron
5
5
  * `schedule` (see `JobConfig.trigger` in `../routines.ts`). This module turns
6
- * an incoming GitHub webhook into the set of routines it should fire, and
7
- * dispatches those routines through the exact same path a cron fire uses
6
+ * incoming GitHub or Linear webhooks into the set of routines they should fire,
7
+ * and dispatches those routines through the exact same path a cron fire uses
8
8
  * (`executeJobDetached`).
9
9
  *
10
- * The matching logic (`matchJobsToWebhook`) is a pure, side-effect-free
11
- * function so it can be unit-tested without a running daemon or http server.
12
- * The optional http listener (`startWebhookServer`) is a thin adapter over it.
10
+ * The matching logic is pure, so it can be unit-tested without a daemon or HTTP
11
+ * server. The listener adds the public-ingress requirements: raw-body HMAC
12
+ * verification, idempotency, source allow-listing, and rate limiting.
13
13
  */
14
14
  import * as http from 'http';
15
+ import type { IncomingHttpHeaders } from 'http';
15
16
  import type { JobConfig, RunMeta } from '../routines.js';
16
- /**
17
- * A parsed GitHub webhook: the event name (from the `X-GitHub-Event` HTTP
18
- * header) plus the decoded JSON body. Kept deliberately loose (`payload` is an
19
- * arbitrary object) because callers may hand us any of GitHub's event shapes.
20
- */
21
- export interface GithubWebhook {
22
- /** The GitHub event name, e.g. `pull_request`, `push`, `issue_comment`. */
17
+ export type WebhookSource = 'github' | 'linear';
18
+ export interface IncomingWebhook {
19
+ /** Delivery source, derived from `/hooks/<source>` or one-shot command flags. */
20
+ source: WebhookSource;
21
+ /** Source event name: GitHub header event or Linear payload `type`. */
23
22
  event: string;
24
- /** The decoded JSON request body. */
23
+ /** Decoded JSON request body. */
25
24
  payload: Record<string, unknown>;
26
25
  }
26
+ export type GithubWebhook = IncomingWebhook & {
27
+ source: 'github';
28
+ };
29
+ export type LinearWebhook = IncomingWebhook & {
30
+ source: 'linear';
31
+ };
27
32
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
28
33
  export declare function webhookRepo(payload: Record<string, unknown>): string | null;
29
34
  /**
@@ -37,14 +42,13 @@ export declare function webhookRepo(payload: Record<string, unknown>): string |
37
42
  */
38
43
  export declare function webhookBranches(event: string, payload: Record<string, unknown>): string[];
39
44
  /** True when a single job's trigger matches the given webhook. Pure. */
40
- export declare function jobMatchesWebhook(job: JobConfig, webhook: GithubWebhook): boolean;
45
+ export declare function jobMatchesWebhook(job: JobConfig, webhook: IncomingWebhook): boolean;
41
46
  /**
42
47
  * Pure matcher: given a set of jobs and an incoming webhook, return the jobs
43
- * whose `trigger` matches (event + optional repo + optional branch). Jobs
44
- * without a trigger (schedule-only routines) are never selected proving
45
- * time-based jobs are unaffected by webhook delivery.
48
+ * whose `trigger` matches. Jobs without a trigger (schedule-only routines) are
49
+ * never selected proving time-based jobs are unaffected by webhook delivery.
46
50
  */
47
- export declare function matchJobsToWebhook(jobs: JobConfig[], webhook: GithubWebhook): JobConfig[];
51
+ export declare function matchJobsToWebhook(jobs: JobConfig[], webhook: IncomingWebhook): JobConfig[];
48
52
  /** Options for firing webhook-matched jobs (dispatch is injectable for tests). */
49
53
  export interface FireWebhookOptions {
50
54
  /** Job source. Defaults to all persisted routines (`listJobs()`). */
@@ -55,31 +59,70 @@ export interface FireWebhookOptions {
55
59
  * matching without spawning real agent processes.
56
60
  */
57
61
  dispatch?: (config: JobConfig) => Promise<RunMeta>;
62
+ /** Matched job names that already completed for this delivery. */
63
+ skipJobNames?: ReadonlySet<string>;
64
+ /** Called immediately after a single matched job dispatch succeeds. */
65
+ onJobFired?: (job: JobConfig, fired: FiredJob) => void;
58
66
  }
59
67
  /** Result of firing one matched job. */
60
68
  export interface FiredJob {
61
69
  jobName: string;
62
70
  runId: string;
63
71
  }
72
+ export declare class WebhookDispatchError extends Error {
73
+ readonly fired: FiredJob[];
74
+ readonly failures: {
75
+ jobName: string;
76
+ error: Error;
77
+ }[];
78
+ constructor(message: string, fired: FiredJob[], failures: {
79
+ jobName: string;
80
+ error: Error;
81
+ }[]);
82
+ }
64
83
  /**
65
84
  * Match an incoming webhook against the persisted routines and fire each match
66
85
  * through the cron dispatch path. Returns one entry per fired job.
67
86
  */
68
- export declare function fireWebhookJobs(webhook: GithubWebhook, options?: FireWebhookOptions): Promise<FiredJob[]>;
87
+ export declare function fireWebhookJobs(webhook: IncomingWebhook, options?: FireWebhookOptions): Promise<FiredJob[]>;
88
+ export declare function verifyGithubSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
89
+ export declare function verifyLinearSignature(headers: IncomingHttpHeaders, rawBody: Buffer, secret: string): boolean;
90
+ export declare function verifyLinearTimestamp(payload: Record<string, unknown>, now?: number, toleranceMs?: number): boolean;
91
+ export interface WebhookSecrets {
92
+ github?: string;
93
+ linear?: string;
94
+ }
95
+ export interface DeliveryStore {
96
+ seen(id: string): boolean;
97
+ mark(id: string): void;
98
+ completedJobs(id: string): ReadonlySet<string>;
99
+ markJob(id: string, jobName: string): void;
100
+ }
101
+ export declare function createMemoryDeliveryStore(maxEntries?: number): DeliveryStore;
102
+ export interface RateLimiter {
103
+ take(key: string): boolean;
104
+ }
105
+ export declare function createMemoryRateLimiter(limit: number, windowMs: number): RateLimiter;
69
106
  /** Options for the local webhook http listener. */
70
107
  export interface WebhookServerOptions {
71
108
  port?: number;
72
109
  host?: string;
110
+ /** HMAC signing secrets keyed by source. */
111
+ secrets: WebhookSecrets;
73
112
  /** Override the fire options (mainly for tests). */
74
113
  fire?: FireWebhookOptions;
75
114
  /** Called after each delivery is handled (mainly for tests/observability). */
76
- onDelivery?: (event: string, fired: FiredJob[]) => void;
115
+ onDelivery?: (webhook: IncomingWebhook, fired: FiredJob[]) => void;
116
+ deliveryStore?: DeliveryStore;
117
+ rateLimiter?: RateLimiter;
118
+ rateLimitPerMinute?: number;
119
+ maxBodyBytes?: number;
77
120
  }
78
121
  /**
79
- * Start a minimal local webhook receiver. POSTs are read as JSON, the GitHub
80
- * event is taken from the `X-GitHub-Event` header, and matching routines are
81
- * fired via {@link fireWebhookJobs}. Returns the underlying server so callers
82
- * can `close()` it. The heavy lifting is the pure matcher above; this is only
83
- * the transport.
122
+ * Start a localhost-bound receiver. It accepts only:
123
+ * POST /hooks/github with X-Hub-Signature-256
124
+ * POST /hooks/linear with Linear-Signature + fresh webhookTimestamp
125
+ *
126
+ * Returns the underlying server so callers can `close()` it.
84
127
  */
85
- export declare function startWebhookServer(options?: WebhookServerOptions): http.Server;
128
+ export declare function startWebhookServer(options: WebhookServerOptions): http.Server;