@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
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
+ import * as yaml from 'yaml';
13
14
  import { execFileSync } from 'child_process';
14
15
  import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
15
16
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
@@ -22,6 +23,7 @@ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace,
22
23
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
24
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
25
  const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
26
+ const HERMES_PLUGIN_MANIFEST_FILE = 'plugin.yaml';
25
27
  const USER_CONFIG_FILE = '.user-config.json';
26
28
  const SOURCE_FILE = '.source';
27
29
  export const PLUGIN_EXEC_SURFACE_LABELS = {
@@ -533,6 +535,16 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
533
535
  result.skills.push(plugin.name);
534
536
  return result;
535
537
  }
538
+ // Hermes loads plugins from a flat $HOME/.hermes/plugins/<name>/ dir with a
539
+ // plugin.yaml manifest, gated by a plugins.enabled allowlist in config.yaml.
540
+ if (agent === 'hermes') {
541
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
542
+ const ok = installHermesPlugin(plugin, versionHome, enablePlugin);
543
+ result.success = ok;
544
+ if (ok)
545
+ result.skills.push(plugin.name);
546
+ return result;
547
+ }
536
548
  const userConfig = loadUserConfig(plugin.name);
537
549
  // Route every marketplace op through the plugin's own marketplace, so a plugin
538
550
  // discovered in an extra/project repo installs under its own
@@ -1035,6 +1047,102 @@ export function removeGoosePlugin(pluginName, versionHome) {
1035
1047
  fs.rmSync(destRoot, { recursive: true, force: true });
1036
1048
  return true;
1037
1049
  }
1050
+ // ─── Hermes plugins (flat ~/.hermes/plugins/ + config.yaml enable toggle) ─────
1051
+ /**
1052
+ * Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
1053
+ * directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
1054
+ * Under agents-cli version isolation HOME is the version home, so we install to:
1055
+ * {versionHome}/.hermes/plugins/<name>/
1056
+ * A plugin does not load until its name is added to `plugins.enabled` (a YAML
1057
+ * array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
1058
+ * wins on conflict, so agents-cli only manages the `enabled` allowlist and never
1059
+ * touches `disabled` (user-owned).
1060
+ */
1061
+ export function hermesPluginsDir(versionHome) {
1062
+ return path.join(versionHome, '.hermes', 'plugins');
1063
+ }
1064
+ function hermesConfigPath(versionHome) {
1065
+ return path.join(versionHome, '.hermes', 'config.yaml');
1066
+ }
1067
+ function writeHermesPluginManifest(plugin, destRoot) {
1068
+ const manifest = {
1069
+ name: plugin.manifest.name,
1070
+ version: plugin.manifest.version,
1071
+ description: plugin.manifest.description,
1072
+ };
1073
+ fs.writeFileSync(path.join(destRoot, HERMES_PLUGIN_MANIFEST_FILE), yaml.stringify(manifest), 'utf-8');
1074
+ }
1075
+ /**
1076
+ * Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
1077
+ * preserving every other key (read → mutate → write). Never touches
1078
+ * `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
1079
+ */
1080
+ export function setHermesPluginEnabled(pluginName, versionHome, enabled) {
1081
+ const configPath = hermesConfigPath(versionHome);
1082
+ let config = {};
1083
+ if (fs.existsSync(configPath)) {
1084
+ const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
1085
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1086
+ config = parsed;
1087
+ }
1088
+ }
1089
+ if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) {
1090
+ config.plugins = {};
1091
+ }
1092
+ const plugins = config.plugins;
1093
+ const current = Array.isArray(plugins.enabled) ? plugins.enabled.filter((n) => typeof n === 'string') : [];
1094
+ const has = current.includes(pluginName);
1095
+ if (enabled && !has) {
1096
+ plugins.enabled = [...current, pluginName];
1097
+ }
1098
+ else if (!enabled && has) {
1099
+ plugins.enabled = current.filter((n) => n !== pluginName);
1100
+ }
1101
+ else {
1102
+ return; // desired state already holds — no rewrite
1103
+ }
1104
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1105
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
1106
+ }
1107
+ export function installHermesPlugin(plugin, versionHome, enable) {
1108
+ const destRoot = path.join(hermesPluginsDir(versionHome), plugin.name);
1109
+ try {
1110
+ if (fs.existsSync(destRoot)) {
1111
+ fs.rmSync(destRoot, { recursive: true, force: true });
1112
+ }
1113
+ fs.cpSync(plugin.root, destRoot, { recursive: true });
1114
+ const userConfig = loadUserConfig(plugin.name);
1115
+ if (Object.keys(userConfig).length > 0) {
1116
+ expandUserConfigInDir(destRoot, userConfig);
1117
+ }
1118
+ writeHermesPluginManifest(plugin, destRoot);
1119
+ fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
1120
+ // Enable only when trusted — never DOWN-toggle here. An un-flagged background
1121
+ // re-sync of an exec-surface plugin passes enable=false; forcing the allowlist
1122
+ // to false then would clobber a plugin the user deliberately enabled with
1123
+ // --allow-exec-surfaces. Mirror addPluginToSettings: add-if-trusted, else leave
1124
+ // the existing enabled state untouched. (Removal still unregisters explicitly.)
1125
+ if (enable) {
1126
+ setHermesPluginEnabled(plugin.name, versionHome, true);
1127
+ }
1128
+ return true;
1129
+ }
1130
+ catch {
1131
+ return false;
1132
+ }
1133
+ }
1134
+ export function isHermesPluginInstalled(pluginName, versionHome) {
1135
+ return fs.existsSync(path.join(hermesPluginsDir(versionHome), pluginName, HERMES_PLUGIN_MANIFEST_FILE));
1136
+ }
1137
+ export function removeHermesPlugin(pluginName, versionHome) {
1138
+ const destRoot = path.join(hermesPluginsDir(versionHome), pluginName);
1139
+ const existed = fs.existsSync(destRoot);
1140
+ if (existed)
1141
+ fs.rmSync(destRoot, { recursive: true, force: true });
1142
+ // Always drop it from the enabled allowlist, even if the dir was already gone.
1143
+ setHermesPluginEnabled(pluginName, versionHome, false);
1144
+ return existed;
1145
+ }
1038
1146
  // ─── Sync status ──────────────────────────────────────────────────────────────
1039
1147
  /**
1040
1148
  * Check if a plugin is synced to a version. True when the plugin lives at the
@@ -1053,6 +1161,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
1053
1161
  if (agent === 'goose') {
1054
1162
  return isGoosePluginInstalled(plugin.name, versionHome);
1055
1163
  }
1164
+ if (agent === 'hermes') {
1165
+ return isHermesPluginInstalled(plugin.name, versionHome);
1166
+ }
1056
1167
  const spec = marketplaceSpecForName(plugin.marketplace);
1057
1168
  if (!isInstalledInMarketplace(plugin.name, spec, agent, versionHome))
1058
1169
  return false;
@@ -1101,6 +1212,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
1101
1212
  }
1102
1213
  return result;
1103
1214
  }
1215
+ // Hermes: remove the flat plugin dir and drop it from config.yaml plugins.enabled.
1216
+ if (agent === 'hermes') {
1217
+ if (removeHermesPlugin(pluginName, versionHome)) {
1218
+ result.skills.push(pluginName);
1219
+ }
1220
+ return result;
1221
+ }
1104
1222
  // 1. Remove the plugin from every marketplace it's installed under. A name can
1105
1223
  // appear in more than one (collision across repos), so we sweep them all.
1106
1224
  let removedAny = false;
@@ -75,6 +75,8 @@ function getAgentConfigPath(agent, versionHome) {
75
75
  return path.join(versionHome, '.factory', 'settings.json');
76
76
  case 'kiro':
77
77
  return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
78
+ case 'openclaw':
79
+ return path.join(versionHome, '.openclaw', 'openclaw.json');
78
80
  case 'goose':
79
81
  return path.join(versionHome, '.config', 'goose', 'permission.yaml');
80
82
  default:
@@ -18,6 +18,10 @@ export interface JobAllowConfig {
18
18
  export type GithubTriggerEvent = 'pull_request' | 'push' | 'issue_comment' | 'workflow_run';
19
19
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
20
20
  export declare const GITHUB_TRIGGER_EVENTS: readonly GithubTriggerEvent[];
21
+ /** Linear webhook resource types a routine can be triggered by. */
22
+ export type LinearTriggerEvent = 'Issue' | 'IssueLabel' | 'Comment' | 'Project' | 'Cycle';
23
+ /** Canonical set of accepted Linear trigger events — single source for validation. */
24
+ export declare const LINEAR_TRIGGER_EVENTS: readonly LinearTriggerEvent[];
21
25
  /**
22
26
  * Map a user-facing `--on` alias to a canonical GitHub trigger event.
23
27
  * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
@@ -26,11 +30,11 @@ export declare const GITHUB_TRIGGER_EVENTS: readonly GithubTriggerEvent[];
26
30
  export declare function normalizeTriggerEvent(input: string): GithubTriggerEvent | null;
27
31
  /**
28
32
  * Event-based fire condition for a routine — an alternative (or complement) to
29
- * `schedule`. Currently only `github_event`: an incoming GitHub webhook whose
30
- * event (and optional repo/branch) match fires the job through the same
31
- * dispatch path a cron fire uses. See `src/lib/triggers/webhook.ts`.
33
+ * `schedule`. Incoming webhooks whose source-specific filters match fire the job
34
+ * through the same dispatch path a cron fire uses. See
35
+ * `src/lib/triggers/webhook.ts`.
32
36
  */
33
- export interface JobTrigger {
37
+ export interface GithubJobTrigger {
34
38
  type: 'github_event';
35
39
  event: GithubTriggerEvent;
36
40
  /** `owner/name` — when set, only payloads for this repo match. */
@@ -38,6 +42,17 @@ export interface JobTrigger {
38
42
  /** git branch (ref short name) — when set, only payloads for this branch match. */
39
43
  branch?: string;
40
44
  }
45
+ export interface LinearJobTrigger {
46
+ type: 'linear_event';
47
+ event: LinearTriggerEvent;
48
+ /** Linear action, e.g. `create`, `update`, `remove`. */
49
+ action?: string;
50
+ /** Issue identifier prefix such as `RUSH`; useful when one webhook spans teams. */
51
+ teamKey?: string;
52
+ /** Required issue label name. */
53
+ label?: string;
54
+ }
55
+ export type JobTrigger = GithubJobTrigger | LinearJobTrigger;
41
56
  /**
42
57
  * Full configuration for a routine (persisted as YAML).
43
58
  *
@@ -126,16 +141,20 @@ export interface JobEligibilityResult {
126
141
  */
127
142
  export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
128
143
  /**
129
- * List all job configs, scanning project > user routine dirs.
130
- * Project routines (`<project>/.agents/routines/`) shadow user routines of the
131
- * same name. Project discovery is opt-in via `cwd`; the daemon (which calls
132
- * `listJobs()` with no argument) only sees user routines.
144
+ * List all job configs, scanning project > user > system routine dirs.
145
+ * Higher layers shadow lower ones of the same name (first-seen wins): a project
146
+ * routine shadows a user routine, and a user routine shadows a built-in system
147
+ * routine (`~/.agents/.system/routines/`, shipped via gh:phnx-labs/.agents-system).
148
+ * Project discovery is opt-in via `cwd`; the daemon (which calls `listJobs()`
149
+ * with no argument) sees user + system routines, so a built-in routine fires for
150
+ * every install unless the user overrides or disables it by name.
133
151
  */
134
152
  export declare function listJobs(cwd?: string): JobConfig[];
135
153
  /**
136
- * Read a single job config by name, checking project > user.
154
+ * Read a single job config by name, checking project > user > system.
137
155
  * Project discovery is opt-in via `cwd`; daemon callers pass no argument and
138
- * only resolve user routines.
156
+ * resolve user + system routines (a user routine of the same name shadows a
157
+ * built-in system routine).
139
158
  */
140
159
  export declare function readJob(name: string, cwd?: string): JobConfig | null;
141
160
  /** Write a job config to disk, omitting fields that match defaults.
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
- import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
13
+ import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin } from './paths.js';
15
15
  import { atomicWriteFileSync } from './fs-atomic.js';
16
16
  import { ALL_AGENT_IDS } from './agents.js';
@@ -22,6 +22,14 @@ export const GITHUB_TRIGGER_EVENTS = [
22
22
  'issue_comment',
23
23
  'workflow_run',
24
24
  ];
25
+ /** Canonical set of accepted Linear trigger events — single source for validation. */
26
+ export const LINEAR_TRIGGER_EVENTS = [
27
+ 'Issue',
28
+ 'IssueLabel',
29
+ 'Comment',
30
+ 'Project',
31
+ 'Cycle',
32
+ ];
25
33
  /**
26
34
  * Map a user-facing `--on` alias to a canonical GitHub trigger event.
27
35
  * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
@@ -79,10 +87,13 @@ const JOB_DEFAULTS = {
79
87
  enabled: true,
80
88
  };
81
89
  /**
82
- * List all job configs, scanning project > user routine dirs.
83
- * Project routines (`<project>/.agents/routines/`) shadow user routines of the
84
- * same name. Project discovery is opt-in via `cwd`; the daemon (which calls
85
- * `listJobs()` with no argument) only sees user routines.
90
+ * List all job configs, scanning project > user > system routine dirs.
91
+ * Higher layers shadow lower ones of the same name (first-seen wins): a project
92
+ * routine shadows a user routine, and a user routine shadows a built-in system
93
+ * routine (`~/.agents/.system/routines/`, shipped via gh:phnx-labs/.agents-system).
94
+ * Project discovery is opt-in via `cwd`; the daemon (which calls `listJobs()`
95
+ * with no argument) sees user + system routines, so a built-in routine fires for
96
+ * every install unless the user overrides or disables it by name.
86
97
  */
87
98
  export function listJobs(cwd) {
88
99
  ensureAgentsDir();
@@ -95,6 +106,7 @@ export function listJobs(cwd) {
95
106
  dirs.push(projectDir);
96
107
  }
97
108
  dirs.push(getRoutinesDir());
109
+ dirs.push(getSystemRoutinesDir());
98
110
  for (const dir of dirs) {
99
111
  if (!fs.existsSync(dir))
100
112
  continue;
@@ -112,9 +124,10 @@ export function listJobs(cwd) {
112
124
  return jobs;
113
125
  }
114
126
  /**
115
- * Read a single job config by name, checking project > user.
127
+ * Read a single job config by name, checking project > user > system.
116
128
  * Project discovery is opt-in via `cwd`; daemon callers pass no argument and
117
- * only resolve user routines.
129
+ * resolve user + system routines (a user routine of the same name shadows a
130
+ * built-in system routine).
118
131
  */
119
132
  export function readJob(name, cwd) {
120
133
  ensureAgentsDir();
@@ -125,6 +138,7 @@ export function readJob(name, cwd) {
125
138
  dirs.push(projectDir);
126
139
  }
127
140
  dirs.push(getRoutinesDir());
141
+ dirs.push(getSystemRoutinesDir());
128
142
  for (const dir of dirs) {
129
143
  for (const ext of ['.yml', '.yaml']) {
130
144
  const filePath = safeJoin(dir, name + ext);
@@ -358,17 +372,35 @@ export function validateTrigger(trigger) {
358
372
  return ['trigger must be an object'];
359
373
  }
360
374
  const t = trigger;
361
- if (t.type !== 'github_event') {
362
- errors.push("trigger.type must be 'github_event'");
375
+ if (t.type !== 'github_event' && t.type !== 'linear_event') {
376
+ errors.push("trigger.type must be 'github_event' or 'linear_event'");
377
+ return errors;
378
+ }
379
+ if (t.type === 'github_event') {
380
+ const github = t;
381
+ if (!github.event || !GITHUB_TRIGGER_EVENTS.includes(github.event)) {
382
+ errors.push(`trigger.event must be one of: ${GITHUB_TRIGGER_EVENTS.join(', ')}`);
383
+ }
384
+ if (github.repo !== undefined && (typeof github.repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(github.repo))) {
385
+ errors.push('trigger.repo must be in owner/name form');
386
+ }
387
+ if (github.branch !== undefined && typeof github.branch !== 'string') {
388
+ errors.push('trigger.branch must be a string');
389
+ }
390
+ return errors;
391
+ }
392
+ const linear = t;
393
+ if (!linear.event || !LINEAR_TRIGGER_EVENTS.includes(linear.event)) {
394
+ errors.push(`trigger.event must be one of: ${LINEAR_TRIGGER_EVENTS.join(', ')}`);
363
395
  }
364
- if (!t.event || !GITHUB_TRIGGER_EVENTS.includes(t.event)) {
365
- errors.push(`trigger.event must be one of: ${GITHUB_TRIGGER_EVENTS.join(', ')}`);
396
+ if (linear.action !== undefined && typeof linear.action !== 'string') {
397
+ errors.push('trigger.action must be a string');
366
398
  }
367
- if (t.repo !== undefined && (typeof t.repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(t.repo))) {
368
- errors.push('trigger.repo must be in owner/name form');
399
+ if (linear.teamKey !== undefined && (typeof linear.teamKey !== 'string' || !/^[A-Z][A-Z0-9]*$/.test(linear.teamKey))) {
400
+ errors.push('trigger.teamKey must be an uppercase Linear team key');
369
401
  }
370
- if (t.branch !== undefined && typeof t.branch !== 'string') {
371
- errors.push('trigger.branch must be a string');
402
+ if (linear.label !== undefined && typeof linear.label !== 'string') {
403
+ errors.push('trigger.label must be a string');
372
404
  }
373
405
  return errors;
374
406
  }
@@ -1,6 +1,6 @@
1
1
  import type { CloudTaskStatus } from '../cloud/types.js';
2
2
  import { type PidSessionEntry } from './pid-registry.js';
3
- import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
3
+ import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
4
4
  import type { SessionAttachment } from './types.js';
5
5
  import { type SessionProvenance } from './provenance.js';
6
6
  /**
@@ -46,6 +46,13 @@ export interface ActiveSession {
46
46
  * says whether it is still pending.
47
47
  */
48
48
  plan?: string;
49
+ /**
50
+ * Live plan progress from the most recent `TodoWrite` (RUSH-1380): the checklist
51
+ * items + a done/total tally + the current step. The Factory Floor renders this
52
+ * as an N/M pill + checklist for every session — including remote and
53
+ * device-dispatched agents that have no local tool-call stream to parse.
54
+ */
55
+ todos?: TodoProgress;
49
56
  /** Last few assistant turns (most-recent last), for at-a-glance context in the UI. */
50
57
  tail?: string[];
51
58
  /** PR opened during the session. */
@@ -267,6 +267,7 @@ function applyState(base, state, fallbackFile) {
267
267
  awaitingReason: state.awaitingReason,
268
268
  question: state.question,
269
269
  plan: state.plan,
270
+ todos: state.todos,
270
271
  tail: state.tail,
271
272
  // Prefer the live preview (latest turn); keep the first-prompt topic as a fallback.
272
273
  preview: state.preview ?? base.preview,
@@ -225,6 +225,18 @@ export function summarizeToolUse(tool, args) {
225
225
  const steps = Array.isArray(args.plan) ? args.plan.length : 0;
226
226
  return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
227
227
  }
228
+ // Claude's live checklist: show progress + the current step, not a bare "TodoWrite".
229
+ case 'TodoWrite': {
230
+ const todos = Array.isArray(args.todos) ? args.todos : [];
231
+ if (todos.length === 0)
232
+ return 'Plan: 0 steps';
233
+ const done = todos.filter((t) => t?.status === 'completed').length;
234
+ const active = todos.find((t) => t?.status === 'in_progress');
235
+ const step = active?.activeForm || active?.content;
236
+ return step
237
+ ? `Plan ${done}/${todos.length}: ${truncate(String(step), 80)}`
238
+ : `Plan: ${done}/${todos.length} done`;
239
+ }
228
240
  // Codex tools
229
241
  case 'exec_command':
230
242
  return `Bash: ${truncate(String(args.command || args.cmd || '').replace(/\n/g, ' ').trim(), 120)}`;
@@ -41,6 +41,28 @@ export interface StructuredQuestion {
41
41
  reason: AwaitingReason;
42
42
  options?: QuestionOption[];
43
43
  }
44
+ /** One `TodoWrite` checklist item, as Claude's plan tool emits it. */
45
+ export interface TodoItem {
46
+ content: string;
47
+ status: 'pending' | 'in_progress' | 'completed';
48
+ /** Present-continuous label shown while this item is the active step. */
49
+ activeForm?: string;
50
+ }
51
+ /**
52
+ * Live plan progress derived from the most recent `TodoWrite` in the transcript
53
+ * (RUSH-1380). Lets the Factory Floor show "N/M done" + the current step for any
54
+ * session — including remote / device-dispatched agents that carry no local
55
+ * tool-call stream — instead of only a coarse working/idle verb.
56
+ */
57
+ export interface TodoProgress {
58
+ items: TodoItem[];
59
+ /** Count of completed items. */
60
+ done: number;
61
+ /** Total items. */
62
+ total: number;
63
+ /** The in-progress item's activeForm (falls back to its content). The live step. */
64
+ activeForm?: string;
65
+ }
44
66
  export interface DetectedPr {
45
67
  url: string;
46
68
  number?: number;
@@ -88,6 +110,12 @@ export interface SessionState {
88
110
  * render the actual plan without re-parsing the transcript.
89
111
  */
90
112
  plan?: string;
113
+ /**
114
+ * Live plan progress from the most recent `TodoWrite` (RUSH-1380). Present when
115
+ * the session has written a todo list; drives the Factory Floor N/M pill +
116
+ * checklist, notably for remote/device-dispatched agents with no local stream.
117
+ */
118
+ todos?: TodoProgress;
91
119
  /** Last few assistant turns (most-recent last), one line each — panel context. */
92
120
  tail?: string[];
93
121
  lastActivityMs?: number;
@@ -111,6 +139,11 @@ export interface StateContext {
111
139
  /** Override the running window (defaults to 2 min, matching active.ts). */
112
140
  activeWindowMs?: number;
113
141
  }
142
+ /**
143
+ * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
144
+ * when there is no usable list, so a session with no plan carries no `todos` field.
145
+ */
146
+ export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
114
147
  /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
115
148
  export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
116
149
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
@@ -47,6 +47,40 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
47
47
  /** Claude tool names that structurally mean "the agent handed control back to you". */
48
48
  const PLAN_TOOL = 'ExitPlanMode';
49
49
  const ASK_TOOL = 'AskUserQuestion';
50
+ /** Claude's live plan/checklist tool. */
51
+ const TODO_TOOL = 'TodoWrite';
52
+ /**
53
+ * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
54
+ * when there is no usable list, so a session with no plan carries no `todos` field.
55
+ */
56
+ export function extractTodoProgress(args) {
57
+ const raw = args?.todos;
58
+ if (!Array.isArray(raw) || raw.length === 0)
59
+ return undefined;
60
+ const items = [];
61
+ for (const t of raw) {
62
+ const activeForm = typeof t?.activeForm === 'string' && t.activeForm ? t.activeForm : undefined;
63
+ const content = typeof t?.content === 'string' && t.content
64
+ ? t.content
65
+ : typeof t?.text === 'string' && t.text
66
+ ? t.text
67
+ : activeForm ?? '';
68
+ if (!content)
69
+ continue;
70
+ const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
71
+ items.push(activeForm ? { content, status, activeForm } : { content, status });
72
+ }
73
+ if (items.length === 0)
74
+ return undefined;
75
+ const done = items.filter(i => i.status === 'completed').length;
76
+ const inProgress = items.find(i => i.status === 'in_progress');
77
+ return {
78
+ items,
79
+ done,
80
+ total: items.length,
81
+ activeForm: inProgress ? inProgress.activeForm ?? inProgress.content : undefined,
82
+ };
83
+ }
50
84
  /** Trailing '?' or a leading interrogative — a question aimed at the user. */
51
85
  const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
52
86
  const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
@@ -281,12 +315,18 @@ export function inferActivity(events, ctx = {}) {
281
315
  .slice(-3)
282
316
  .map(e => oneLine(e.content ?? ''))
283
317
  .filter(Boolean);
318
+ // Live plan progress: the most recent TodoWrite's checklist (RUSH-1380). Attached
319
+ // to `base` so every return path below carries it — a working, waiting, or idle
320
+ // session all keep showing how far the plan got.
321
+ const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && e.tool === TODO_TOOL);
322
+ const todos = lastTodo ? extractTodoProgress(lastTodo.args) : undefined;
284
323
  const base = {
285
324
  activity: 'idle',
286
325
  lastRole: lastMsg?.role,
287
326
  lastEventKind: last?.type,
288
327
  lastActivityMs: ctx.mtimeMs,
289
328
  preview: previewSource ? describeEvent(previewSource) : undefined,
329
+ todos,
290
330
  tail: tail.length ? tail : undefined,
291
331
  };
292
332
  if (!last)
@@ -24,6 +24,8 @@ export interface SyncAgentSpec {
24
24
  id: string;
25
25
  /** Config subdir under the agent home that holds transcripts. */
26
26
  subdir: string;
27
+ /** File extension to walk for this agent (defaults to .jsonl). */
28
+ ext?: string;
27
29
  /** Derive the session id from a storage-relative key. */
28
30
  sessionIdFromRelKey(relKey: string): string;
29
31
  }
@@ -32,6 +32,44 @@ export const SYNC_AGENTS = [
32
32
  // is the session id (matches session_meta.payload.id).
33
33
  sessionIdFromRelKey: rel => path.basename(rel).match(UUID_RE)?.[0] ?? rel,
34
34
  },
35
+ {
36
+ id: 'droid',
37
+ subdir: 'sessions',
38
+ // Droid writes <uuid>.jsonl under per-cwd subdirs (plus an optional
39
+ // <uuid>.settings.json sidecar for metadata). The JSONL is the canonical
40
+ // transcript; the sidecar is rebuilt from the transcript on the mirror.
41
+ sessionIdFromRelKey: rel => path.basename(rel).replace(/\.jsonl$/, ''),
42
+ },
43
+ {
44
+ id: 'grok',
45
+ subdir: 'sessions',
46
+ // Grok sessions are multi-file directories under ~/.grok/sessions/<cwd>/<uuid>/.
47
+ // events.jsonl is the canonical transcript stream; syncing it lets the mirror
48
+ // reconstruct the session (summary.json metadata is recomputed on read).
49
+ sessionIdFromRelKey: rel => path.basename(path.dirname(rel)),
50
+ },
51
+ {
52
+ id: 'kimi',
53
+ subdir: 'sessions',
54
+ ext: '.json',
55
+ // Kimi sessions are multi-file directories under
56
+ // ~/.kimi-code/sessions/<wd_hash>/session_<uuid>/. state.json carries the
57
+ // metadata the scanner reads; wire.jsonl (conversation) is a follow-up
58
+ // tracked by the multi-file-sessions ticket.
59
+ sessionIdFromRelKey: rel => {
60
+ const m = rel.match(/session_[^/]+/);
61
+ return m?.[0] ?? rel;
62
+ },
63
+ },
64
+ {
65
+ id: 'opencode',
66
+ subdir: 'sessions',
67
+ // OpenCode stores sessions in a single SQLite DB (~/.local/share/opencode/opencode.db)
68
+ // rather than transcript files. This entry reserves the agent slot so the
69
+ // sync matrix is complete; file-based round-tripping requires an SQLite-to-JSONL
70
+ // export step (not yet implemented).
71
+ sessionIdFromRelKey: rel => path.basename(rel),
72
+ },
35
73
  ];
36
74
  let cachedMirrorRoot = null;
37
75
  function mirrorRootReal() {
@@ -61,7 +99,7 @@ export function listLocalTranscripts(spec) {
61
99
  for (const dir of getAgentSessionDirs(spec.id, spec.subdir)) {
62
100
  if (safeReal(dir).startsWith(mirror))
63
101
  continue; // skip synced-in mirror dirs
64
- for (const abs of walkForFiles(dir, '.jsonl', 100_000)) {
102
+ for (const abs of walkForFiles(dir, spec.ext ?? '.jsonl', 100_000)) {
65
103
  const relKey = path.relative(dir, abs);
66
104
  if (!relKey || relKey.startsWith('..'))
67
105
  continue;
@@ -12,6 +12,14 @@ export interface R2Config {
12
12
  secretAccessKey: string;
13
13
  /** S3-compatible endpoint for the account (no bucket, no trailing slash). */
14
14
  endpoint: string;
15
+ /**
16
+ * Shared 32-byte key (hex or base64) for client-side transcript encryption,
17
+ * held in the bundle as `R2_SYNC_ENC_KEY`. Optional and deliberately separate
18
+ * from the R2 credentials so rotating the access token never orphans already
19
+ * encrypted objects. When absent, transcripts upload unencrypted (with a loud
20
+ * per-cycle warning) — see transcript-crypto.ts + pushOwn.
21
+ */
22
+ syncEncKey?: string;
15
23
  }
16
24
  /** Window after a prompt-bearing resolution failure during which we skip
17
25
  * re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
@@ -17,6 +17,7 @@ function resolveR2Config() {
17
17
  const bucket = env.R2_BUCKET_NAME?.trim();
18
18
  const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
19
19
  const secretAccessKey = env.R2_SECRET_ACCESS_KEY?.trim();
20
+ const syncEncKey = env.R2_SYNC_ENC_KEY?.trim() || undefined;
20
21
  const missing = [
21
22
  !accountId && 'R2_ACCOUNT_ID',
22
23
  !bucket && 'R2_BUCKET_NAME',
@@ -32,7 +33,11 @@ function resolveR2Config() {
32
33
  bucket: bucket,
33
34
  accessKeyId: accessKeyId,
34
35
  secretAccessKey: secretAccessKey,
35
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
36
+ // Default to the account's R2 endpoint; an explicit R2_ENDPOINT override
37
+ // points sync at any S3-compatible store (MinIO, another provider) — which
38
+ // is also how the feature is verified end-to-end without live R2.
39
+ endpoint: env.R2_ENDPOINT?.trim() || `https://${accountId}.r2.cloudflarestorage.com`,
40
+ syncEncKey,
36
41
  };
37
42
  }
38
43
  // ── Resolution cache ────────────────────────────────────────────────────────