@phnx-labs/agents-cli 1.20.62 → 1.20.64
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 +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- 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/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- 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/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -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/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- 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 +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- 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/mailbox.d.ts
CHANGED
|
@@ -69,3 +69,42 @@ export declare function drain(boxDir: string, boxId?: string, now?: Date): Mailb
|
|
|
69
69
|
export declare function peek(boxDir: string, boxId?: string, now?: Date): MailboxMessage[];
|
|
70
70
|
/** Delete pending (not-yet-claimed) inbox messages. Returns the count removed. */
|
|
71
71
|
export declare function clear(boxDir: string): number;
|
|
72
|
+
/** Which bucket a stored message currently sits in. */
|
|
73
|
+
export type MailboxState = 'inbox' | 'processing' | 'consumed';
|
|
74
|
+
/** A message read back from a box, tagged with the bucket it was found in. */
|
|
75
|
+
export interface StoredMessage extends MailboxMessage {
|
|
76
|
+
state: MailboxState;
|
|
77
|
+
}
|
|
78
|
+
/** A stored message enriched with the mailbox identity used by comms renderers. */
|
|
79
|
+
export interface CommsMsg {
|
|
80
|
+
from: string;
|
|
81
|
+
to: string;
|
|
82
|
+
toLabel: string;
|
|
83
|
+
ts: string;
|
|
84
|
+
text: string;
|
|
85
|
+
state: MailboxState;
|
|
86
|
+
box: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Enumerate the box ids under `root` (directory names that are valid mailbox
|
|
90
|
+
* ids). Read-only; does not create the root. Sorted for stable output.
|
|
91
|
+
*/
|
|
92
|
+
export declare function listBoxes(root?: string): string[];
|
|
93
|
+
/**
|
|
94
|
+
* Read every message in a box across all three buckets (inbox, processing,
|
|
95
|
+
* consumed) WITHOUT consuming, sweeping, or archiving anything. Unlike `peek`,
|
|
96
|
+
* this includes `consumed/` — the delivered history — so callers can surface a
|
|
97
|
+
* communication log. Each row is tagged with its bucket. Sorted FIFO by msgId
|
|
98
|
+
* (which is time-sortable), oldest first.
|
|
99
|
+
*/
|
|
100
|
+
export declare function readBox(boxDir: string): StoredMessage[];
|
|
101
|
+
/**
|
|
102
|
+
* Poll the complete spool and yield each message once when its box/msgId pair
|
|
103
|
+
* first appears. Existing messages establish the initial baseline unless
|
|
104
|
+
* `backfill` is requested; moving a message between buckets does not re-emit it.
|
|
105
|
+
*/
|
|
106
|
+
export declare function watchMessages(root: string, opts: {
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
intervalMs?: number;
|
|
109
|
+
backfill?: boolean;
|
|
110
|
+
}): AsyncGenerator<CommsMsg>;
|
package/dist/lib/mailbox.js
CHANGED
|
@@ -267,3 +267,115 @@ export function clear(boxDir) {
|
|
|
267
267
|
}
|
|
268
268
|
return n;
|
|
269
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Enumerate the box ids under `root` (directory names that are valid mailbox
|
|
272
|
+
* ids). Read-only; does not create the root. Sorted for stable output.
|
|
273
|
+
*/
|
|
274
|
+
export function listBoxes(root = getMailboxRootDir()) {
|
|
275
|
+
let names;
|
|
276
|
+
try {
|
|
277
|
+
names = fs.readdirSync(root);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
return names.filter((n) => isValidMailboxId(n) && fs.statSync(path.join(root, n)).isDirectory()).sort();
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Read every message in a box across all three buckets (inbox, processing,
|
|
286
|
+
* consumed) WITHOUT consuming, sweeping, or archiving anything. Unlike `peek`,
|
|
287
|
+
* this includes `consumed/` — the delivered history — so callers can surface a
|
|
288
|
+
* communication log. Each row is tagged with its bucket. Sorted FIFO by msgId
|
|
289
|
+
* (which is time-sortable), oldest first.
|
|
290
|
+
*/
|
|
291
|
+
export function readBox(boxDir) {
|
|
292
|
+
const out = [];
|
|
293
|
+
const buckets = [
|
|
294
|
+
[inboxDir(boxDir), 'inbox'],
|
|
295
|
+
[processingDir(boxDir), 'processing'],
|
|
296
|
+
[consumedDir(boxDir), 'consumed'],
|
|
297
|
+
];
|
|
298
|
+
for (const [dir, state] of buckets) {
|
|
299
|
+
for (const name of jsonFiles(dir)) {
|
|
300
|
+
const msg = readMessage(path.join(dir, name));
|
|
301
|
+
if (msg)
|
|
302
|
+
out.push({ ...msg, state });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
out.sort((a, b) => (a.msgId < b.msgId ? -1 : a.msgId > b.msgId ? 1 : 0));
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Poll the complete spool and yield each message once when its box/msgId pair
|
|
310
|
+
* first appears. Existing messages establish the initial baseline unless
|
|
311
|
+
* `backfill` is requested; moving a message between buckets does not re-emit it.
|
|
312
|
+
*/
|
|
313
|
+
export async function* watchMessages(root, opts) {
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
const requestedInterval = opts.intervalMs ?? 500;
|
|
316
|
+
const intervalMs = Number.isFinite(requestedInterval) ? Math.max(1, requestedInterval) : 500;
|
|
317
|
+
let firstPoll = true;
|
|
318
|
+
while (!opts.signal?.aborted) {
|
|
319
|
+
const fresh = [];
|
|
320
|
+
for (const box of listBoxes(root)) {
|
|
321
|
+
for (const stored of readBox(mailboxDir(box, root))) {
|
|
322
|
+
const key = `${box}\0${stored.msgId}`;
|
|
323
|
+
if (seen.has(key))
|
|
324
|
+
continue;
|
|
325
|
+
seen.add(key);
|
|
326
|
+
if (firstPoll && !opts.backfill)
|
|
327
|
+
continue;
|
|
328
|
+
fresh.push({
|
|
329
|
+
key,
|
|
330
|
+
message: {
|
|
331
|
+
from: stored.from || 'operator',
|
|
332
|
+
to: stored.to,
|
|
333
|
+
toLabel: box.slice(0, 8),
|
|
334
|
+
ts: stored.ts,
|
|
335
|
+
text: stored.text,
|
|
336
|
+
state: stored.state,
|
|
337
|
+
box,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
firstPoll = false;
|
|
343
|
+
fresh.sort((a, b) => compareWatched(a.message.ts, b.message.ts) ||
|
|
344
|
+
compareWatched(a.key, b.key));
|
|
345
|
+
for (const { message } of fresh) {
|
|
346
|
+
if (opts.signal?.aborted)
|
|
347
|
+
return;
|
|
348
|
+
yield message;
|
|
349
|
+
}
|
|
350
|
+
if (!await waitForMailboxPoll(intervalMs, opts.signal))
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function compareWatched(a, b) {
|
|
355
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
356
|
+
}
|
|
357
|
+
/** Wait for the next poll, resolving immediately when the watcher is aborted. */
|
|
358
|
+
function waitForMailboxPoll(intervalMs, signal) {
|
|
359
|
+
if (signal?.aborted)
|
|
360
|
+
return Promise.resolve(false);
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
let timer;
|
|
363
|
+
let settled = false;
|
|
364
|
+
const finish = (keepWatching) => {
|
|
365
|
+
if (settled)
|
|
366
|
+
return;
|
|
367
|
+
settled = true;
|
|
368
|
+
if (timer)
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
signal?.removeEventListener('abort', onAbort);
|
|
371
|
+
resolve(keepWatching);
|
|
372
|
+
};
|
|
373
|
+
const onAbort = () => finish(false);
|
|
374
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
375
|
+
if (signal?.aborted) {
|
|
376
|
+
finish(false);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
timer = setTimeout(() => finish(true), intervalMs);
|
|
380
|
+
});
|
|
381
|
+
}
|
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/paths.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `name` is a safe single path segment: non-empty, not '.'/'..',
|
|
3
|
+
* free of path separators and null bytes, and within the filename length limit.
|
|
4
|
+
* Dot-prefixed names like '.env.example' are allowed.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isSafeSegmentName(name: string): boolean;
|
|
1
7
|
/**
|
|
2
8
|
* Resolve base + name while preventing path-traversal attacks.
|
|
3
9
|
* Rejects path separators, null bytes, '.' and '..', and any resolved path
|
|
@@ -6,3 +12,10 @@
|
|
|
6
12
|
* Allows spaces, unicode, and other common filename characters.
|
|
7
13
|
*/
|
|
8
14
|
export declare function safeJoin(base: string, name: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Assert that `target` (which may legitimately contain path separators, e.g. a
|
|
17
|
+
* multi-segment relative key) stays within `root` after normalization. Use this
|
|
18
|
+
* where a caller must accept nested relative paths but the input is untrusted —
|
|
19
|
+
* `safeJoin` is stricter and only allows single segments.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertWithin(root: string, target: string): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* True when `name` is a safe single path segment: non-empty, not '.'/'..',
|
|
4
|
+
* free of path separators and null bytes, and within the filename length limit.
|
|
5
|
+
* Dot-prefixed names like '.env.example' are allowed.
|
|
6
|
+
*/
|
|
7
|
+
export function isSafeSegmentName(name) {
|
|
8
|
+
return (!!name &&
|
|
9
|
+
name !== '.' && name !== '..' &&
|
|
10
|
+
!/[\/\\\x00]/.test(name) &&
|
|
11
|
+
name.length <= 255);
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* Resolve base + name while preventing path-traversal attacks.
|
|
4
15
|
* Rejects path separators, null bytes, '.' and '..', and any resolved path
|
|
@@ -7,10 +18,7 @@ import * as path from 'path';
|
|
|
7
18
|
* Allows spaces, unicode, and other common filename characters.
|
|
8
19
|
*/
|
|
9
20
|
export function safeJoin(base, name) {
|
|
10
|
-
if (!name
|
|
11
|
-
name === '.' || name === '..' ||
|
|
12
|
-
/[\/\\\x00]/.test(name) ||
|
|
13
|
-
name.length > 255) {
|
|
21
|
+
if (!isSafeSegmentName(name)) {
|
|
14
22
|
throw new Error(`Invalid name: ${name}`);
|
|
15
23
|
}
|
|
16
24
|
const resolved = path.resolve(base, name);
|
|
@@ -18,3 +26,17 @@ export function safeJoin(base, name) {
|
|
|
18
26
|
throw new Error(`Path escape: ${name}`);
|
|
19
27
|
return resolved;
|
|
20
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Assert that `target` (which may legitimately contain path separators, e.g. a
|
|
31
|
+
* multi-segment relative key) stays within `root` after normalization. Use this
|
|
32
|
+
* where a caller must accept nested relative paths but the input is untrusted —
|
|
33
|
+
* `safeJoin` is stricter and only allows single segments.
|
|
34
|
+
*/
|
|
35
|
+
export function assertWithin(root, target) {
|
|
36
|
+
const base = path.resolve(root);
|
|
37
|
+
const resolved = path.resolve(target);
|
|
38
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
39
|
+
throw new Error(`Path escape: ${target}`);
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|