@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.
- package/CHANGELOG.md +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
package/dist/lib/plugins.js
CHANGED
|
@@ -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:
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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`.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
*
|
|
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.
|
package/dist/lib/routines.js
CHANGED
|
@@ -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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
365
|
-
errors.push(
|
|
396
|
+
if (linear.action !== undefined && typeof linear.action !== 'string') {
|
|
397
|
+
errors.push('trigger.action must be a string');
|
|
366
398
|
}
|
|
367
|
-
if (
|
|
368
|
-
errors.push('trigger.
|
|
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 (
|
|
371
|
-
errors.push('trigger.
|
|
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
|
|
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 ────────────────────────────────────────────────────────
|