@phnx-labs/agents-cli 1.20.62 → 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 +45 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +41 -0
- 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 +58 -18
- 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/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- 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/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/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- 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/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/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
-
* `agents <command>` on a remote host instead of locally
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
-
* it handled the invocation (the local command must then NOT run).
|
|
3
|
+
* `agents <command>` on a remote host instead of locally. Called once from
|
|
4
|
+
* `index.ts` before commander parses; returns `true` when it handled the
|
|
5
|
+
* invocation (the local command must then NOT run).
|
|
8
6
|
*
|
|
9
7
|
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
8
|
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
@@ -12,9 +10,11 @@
|
|
|
12
10
|
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
11
|
* supervisor outlives a dropped connection.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
|
|
14
|
+
* `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
|
|
15
|
+
* fall through to their local actions. Everything else either routes via this
|
|
16
|
+
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
|
+
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
import chalk from 'chalk';
|
|
20
20
|
import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
|
|
@@ -24,19 +24,90 @@ import { dispatchAgentsCommand } from './dispatch.js';
|
|
|
24
24
|
import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
26
26
|
import { machineId } from '../session/sync/config.js';
|
|
27
|
+
/**
|
|
28
|
+
* First-class groups that run transparently on a remote via SSH when
|
|
29
|
+
* `--host`/`--device` is present. Keep both canonical names and aliases
|
|
30
|
+
* (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
|
|
31
|
+
*
|
|
32
|
+
* Prefer adding here over per-command SSH code — this is the single choke point.
|
|
33
|
+
*/
|
|
27
34
|
const REMOTE_PASSTHROUGH = {
|
|
35
|
+
// status / inspect
|
|
28
36
|
view: {},
|
|
37
|
+
inspect: {},
|
|
38
|
+
doctor: {},
|
|
39
|
+
status: {},
|
|
40
|
+
check: {},
|
|
41
|
+
list: {},
|
|
29
42
|
usage: {},
|
|
30
43
|
cost: {},
|
|
31
44
|
output: {},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
list: {},
|
|
45
|
+
budget: {},
|
|
46
|
+
// config / resources
|
|
35
47
|
sync: { nonInteractive: ['--yes'] },
|
|
48
|
+
pull: {},
|
|
49
|
+
push: {},
|
|
50
|
+
repo: {},
|
|
51
|
+
repos: {},
|
|
52
|
+
plugins: {},
|
|
53
|
+
skills: {},
|
|
54
|
+
hooks: {},
|
|
55
|
+
commands: {},
|
|
56
|
+
rules: {},
|
|
57
|
+
memory: {},
|
|
58
|
+
permissions: {},
|
|
59
|
+
perms: {},
|
|
60
|
+
mcp: {},
|
|
61
|
+
cli: {},
|
|
62
|
+
subagents: {},
|
|
63
|
+
workflows: {},
|
|
64
|
+
packages: {},
|
|
65
|
+
models: {},
|
|
66
|
+
profiles: {},
|
|
67
|
+
defaults: {},
|
|
68
|
+
alias: {},
|
|
69
|
+
// lifecycle
|
|
36
70
|
teams: {},
|
|
37
71
|
message: {},
|
|
38
72
|
routines: {},
|
|
73
|
+
jobs: {},
|
|
74
|
+
cron: {},
|
|
75
|
+
// misc remote-sensible
|
|
76
|
+
prune: {},
|
|
77
|
+
trash: {},
|
|
78
|
+
restore: {},
|
|
79
|
+
worktree: {},
|
|
80
|
+
events: {},
|
|
81
|
+
audit: {},
|
|
82
|
+
lock: {},
|
|
83
|
+
feedback: {},
|
|
84
|
+
wallet: {},
|
|
85
|
+
daemon: {},
|
|
86
|
+
pty: {},
|
|
87
|
+
tmux: {},
|
|
88
|
+
watchdog: {},
|
|
89
|
+
factory: {},
|
|
90
|
+
browser: {},
|
|
91
|
+
versions: {},
|
|
39
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Commands that register and interpret `--host`/`--device` themselves — must
|
|
95
|
+
* fall through to local commander even when the flag is present. Do not add
|
|
96
|
+
* these to {@link REMOTE_PASSTHROUGH}.
|
|
97
|
+
*/
|
|
98
|
+
const OWN_HOST_COMMANDS = new Set([
|
|
99
|
+
'run',
|
|
100
|
+
'exec', // deprecated alias of run
|
|
101
|
+
'sessions',
|
|
102
|
+
'feed',
|
|
103
|
+
'computer',
|
|
104
|
+
'secrets',
|
|
105
|
+
'logs',
|
|
106
|
+
'hosts',
|
|
107
|
+
'ssh',
|
|
108
|
+
'devices',
|
|
109
|
+
'fleet', // alias of devices
|
|
110
|
+
]);
|
|
40
111
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
41
112
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
42
113
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
@@ -83,15 +154,26 @@ async function resolveTargetHost(name, any) {
|
|
|
83
154
|
/**
|
|
84
155
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
85
156
|
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
86
|
-
* `false` (run locally) when neither flag is present, the command
|
|
87
|
-
*
|
|
157
|
+
* `false` (run locally) when neither flag is present, the command owns its own
|
|
158
|
+
* host handling, the target is this very machine, or placement flags need the
|
|
159
|
+
* local action. Returns `true` after printing a clear error when the flag is
|
|
160
|
+
* present on a command that is neither routable nor self-handling — so the user
|
|
161
|
+
* never sees commander's raw `unknown option '--host'`.
|
|
88
162
|
*
|
|
89
163
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
90
164
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
91
165
|
*/
|
|
92
166
|
export async function maybeRunOnHost(command, allArgs) {
|
|
93
|
-
const
|
|
94
|
-
|
|
167
|
+
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
168
|
+
const deviceFlag = flagValue(allArgs, 'device');
|
|
169
|
+
const hostName = hostFlag ?? deviceFlag;
|
|
170
|
+
if (!hostName)
|
|
171
|
+
return false;
|
|
172
|
+
// Commands with their own richer --host semantics must reach local commander
|
|
173
|
+
// BEFORE any single-target conflict gate. sessions/feed merge --host and
|
|
174
|
+
// --device into a multi-host list; rejecting "conflicting" pairs would break
|
|
175
|
+
// `agents sessions --host a --device b` / `agents feed --host a --device b`.
|
|
176
|
+
if (OWN_HOST_COMMANDS.has(command))
|
|
95
177
|
return false;
|
|
96
178
|
// Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
|
|
97
179
|
// (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
|
|
@@ -107,16 +189,6 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
107
189
|
return false;
|
|
108
190
|
}
|
|
109
191
|
}
|
|
110
|
-
// `--device` is a first-class alias of `--host` (mirrors `agents run`); the
|
|
111
|
-
// device registry is the source of truth for machine identity. Reject a
|
|
112
|
-
// conflicting pair rather than silently preferring one — same rule as run.
|
|
113
|
-
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
114
|
-
const deviceFlag = flagValue(allArgs, 'device');
|
|
115
|
-
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
116
|
-
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
117
|
-
process.exitCode = 1;
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
192
|
// `--hosts` is always a generic fleet flag — bail for every command so the
|
|
121
193
|
// local aggregator handles it. `--devices` is fan-out on most commands but
|
|
122
194
|
// a placement flag on `routines` (which devices may run the routine), so
|
|
@@ -125,14 +197,32 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
125
197
|
return false;
|
|
126
198
|
if (allArgs.includes('--devices') && command !== 'routines')
|
|
127
199
|
return false;
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
130
|
-
|
|
200
|
+
const spec = REMOTE_PASSTHROUGH[command];
|
|
201
|
+
if (!spec) {
|
|
202
|
+
// Flag was accepted (no raw commander "unknown option") but this group has
|
|
203
|
+
// no remote semantics — say so clearly instead of falling through.
|
|
204
|
+
console.error(chalk.red(`\`agents ${command}\` does not support --host/--device (no remote interpretation).`) +
|
|
205
|
+
chalk.gray(' Run without the flag, or use a host-routable group (repos, view, sync, teams, doctor, …).'));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
// Single-target remote path only: reject a conflicting --host/--device pair
|
|
210
|
+
// rather than silently preferring one (same rule as `agents run`).
|
|
211
|
+
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
212
|
+
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
131
216
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
132
217
|
// `machineId()` is the same self-identifier the device registry and session
|
|
133
218
|
// sync use (lowercased short hostname); compare case-insensitively.
|
|
134
|
-
|
|
219
|
+
// Strip the routing flags from process.argv so the local command never sees
|
|
220
|
+
// an unregistered `--host`/`--device` and dies with "unknown option".
|
|
221
|
+
if (hostName.toLowerCase() === machineId()) {
|
|
222
|
+
const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
223
|
+
process.argv = [process.argv[0], process.argv[1], ...stripped];
|
|
135
224
|
return false;
|
|
225
|
+
}
|
|
136
226
|
const remoteCwd = flagValue(allArgs, 'remote-cwd');
|
|
137
227
|
const any = allArgs.includes('--any');
|
|
138
228
|
let host;
|
package/dist/lib/mailbox-gc.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import { getMailboxRootDir } from './state.js';
|
|
12
|
-
import { mailboxDir, isValidMailboxId, readMessage, } from './mailbox.js';
|
|
12
|
+
import { mailboxDir, isValidMailboxId, readMessage, sweepExpired, } from './mailbox.js';
|
|
13
13
|
import { listBlocks, removeBlock } from './feed.js';
|
|
14
14
|
const DEFAULT_MAX_CONSUMED_AGE_MINUTES = 24 * 60;
|
|
15
15
|
function consumedAgeMinutes(file, now) {
|
|
@@ -134,21 +134,9 @@ export function gcMailbox(activeBoxIds, options = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
else {
|
|
137
|
-
// Live box:
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
for (const sub of ['inbox', 'processing']) {
|
|
141
|
-
const dir = path.join(boxDir, sub);
|
|
142
|
-
for (const file of jsonFiles(dir)) {
|
|
143
|
-
const msg = readMessage(path.join(dir, file));
|
|
144
|
-
if (msg && msg.expiresAt) {
|
|
145
|
-
const ts = Date.parse(msg.expiresAt);
|
|
146
|
-
if (!Number.isNaN(ts) && ts <= now.getTime()) {
|
|
147
|
-
result.messagesDroppedExpired++;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
137
|
+
// Live box: archive expired messages (same path as drain/peek) so GC does
|
|
138
|
+
// not leave expired files in inbox/processing while only bumping metrics.
|
|
139
|
+
result.messagesDroppedExpired += sweepExpired(boxDir, name, now);
|
|
152
140
|
result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
|
|
153
141
|
}
|
|
154
142
|
}
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ export declare function foldLegacySystemRepo(): void;
|
|
|
44
44
|
* drive a fixture tree without touching the user's ~/.agents.
|
|
45
45
|
*/
|
|
46
46
|
export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Fold the legacy GLOBAL browser captures root
|
|
49
|
+
* (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
|
|
50
|
+
* (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
|
|
51
|
+
* self-contained tree and the new `browser sessions` listing finds old captures.
|
|
52
|
+
*
|
|
53
|
+
* Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
|
|
54
|
+
* `tasks.json` still lists that task name. Tasks no profile claims (the profile
|
|
55
|
+
* was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
|
|
56
|
+
* nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
|
|
57
|
+
*/
|
|
58
|
+
export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): void;
|
|
47
59
|
/**
|
|
48
60
|
* Repair self-referential agent binary symlinks.
|
|
49
61
|
*
|
package/dist/lib/migrate.js
CHANGED
|
@@ -359,6 +359,56 @@ function foldUserHooksYamlIntoAgentsYaml() {
|
|
|
359
359
|
}
|
|
360
360
|
catch { /* best-effort */ }
|
|
361
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Fold the legacy GLOBAL browser captures root
|
|
364
|
+
* (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
|
|
365
|
+
* (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
|
|
366
|
+
* self-contained tree and the new `browser sessions` listing finds old captures.
|
|
367
|
+
*
|
|
368
|
+
* Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
|
|
369
|
+
* `tasks.json` still lists that task name. Tasks no profile claims (the profile
|
|
370
|
+
* was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
|
|
371
|
+
* nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
|
|
372
|
+
*/
|
|
373
|
+
export function foldBrowserSessionsIntoProfiles(browserDir = path.join(CACHE_DIR, 'browser')) {
|
|
374
|
+
const legacySessionsDir = path.join(browserDir, 'sessions');
|
|
375
|
+
let taskDirs;
|
|
376
|
+
try {
|
|
377
|
+
taskDirs = fs.readdirSync(legacySessionsDir, { withFileTypes: true });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return; // no legacy global sessions/ root — already folded or never existed
|
|
381
|
+
}
|
|
382
|
+
// Map task name -> owning profile from every profile's tasks.json (first wins).
|
|
383
|
+
const taskOwner = new Map();
|
|
384
|
+
let profileDirs = [];
|
|
385
|
+
try {
|
|
386
|
+
profileDirs = fs.readdirSync(browserDir, { withFileTypes: true });
|
|
387
|
+
}
|
|
388
|
+
catch { /* browserDir vanished mid-run */ }
|
|
389
|
+
for (const p of profileDirs) {
|
|
390
|
+
if (!p.isDirectory() || p.name === 'sessions' || p.name === '_legacy')
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
const state = JSON.parse(fs.readFileSync(path.join(browserDir, p.name, 'tasks.json'), 'utf-8'));
|
|
394
|
+
for (const taskName of Object.keys(state)) {
|
|
395
|
+
if (!taskOwner.has(taskName))
|
|
396
|
+
taskOwner.set(taskName, p.name);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch { /* missing or invalid tasks.json */ }
|
|
400
|
+
}
|
|
401
|
+
for (const taskEntry of taskDirs) {
|
|
402
|
+
if (!taskEntry.isDirectory())
|
|
403
|
+
continue;
|
|
404
|
+
const owner = taskOwner.get(taskEntry.name) ?? '_legacy';
|
|
405
|
+
// A capture dir can hold a nested recordings/ subdir and may collide with a
|
|
406
|
+
// dest that already has newer captures — moveDirOnce merges (skip-existing),
|
|
407
|
+
// moveFileOnce would EISDIR on unlink and silently strand the captures.
|
|
408
|
+
moveDirOnce(path.join(legacySessionsDir, taskEntry.name), path.join(browserDir, owner, 'sessions', taskEntry.name));
|
|
409
|
+
}
|
|
410
|
+
rmEmptyDirTree(legacySessionsDir);
|
|
411
|
+
}
|
|
362
412
|
/**
|
|
363
413
|
* Fold ~/.agents/browser/profiles/*.yaml into ~/.agents/agents.yaml under a
|
|
364
414
|
* `browser:` key, then delete the profiles directory. Single user file to sync.
|
|
@@ -1582,7 +1632,7 @@ function containsOnlyDsStore(dir) {
|
|
|
1582
1632
|
function warnSystemOrphans() {
|
|
1583
1633
|
const SHIPPED_ALLOWLIST = new Set([
|
|
1584
1634
|
// resource directories shipped by the npm package
|
|
1585
|
-
'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents',
|
|
1635
|
+
'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents', 'routines',
|
|
1586
1636
|
// top-level metadata files
|
|
1587
1637
|
'agents.yaml', 'hooks.yaml', 'README.md', 'CHANGELOG.md',
|
|
1588
1638
|
// git + repo metadata
|
|
@@ -1966,6 +2016,10 @@ export async function runMigration() {
|
|
|
1966
2016
|
// migrateRuntimeToCache so any legacy plugins/ still at the user-root from
|
|
1967
2017
|
// very-old layouts have already been handled.
|
|
1968
2018
|
migratePluginsBackToUserRoot();
|
|
2019
|
+
// Browser captures: fold the legacy global browser/sessions/<task> root into the
|
|
2020
|
+
// per-profile browser/<profile>/sessions/<task> layout. After the cache moves so
|
|
2021
|
+
// the browser dir is at its canonical .cache location.
|
|
2022
|
+
foldBrowserSessionsIntoProfiles();
|
|
1969
2023
|
// System-repo sweep: move every remaining operational dir into its canonical
|
|
1970
2024
|
// user-bucket location, then drop known-dead artifacts and warn about
|
|
1971
2025
|
// anything we don't recognize. Order: durable (sessions/teams/trash/repos/
|
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
|
}
|
|
@@ -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 ────────────────────────────────────────────────────────
|