@phnx-labs/agents-cli 1.20.35 → 1.20.36
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 +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +156 -75
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.js +20 -8
- package/dist/commands/sessions.d.ts +71 -1
- package/dist/commands/sessions.js +365 -37
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +112 -8
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +57 -0
- package/dist/lib/daemon.js +192 -16
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +14 -0
- package/dist/lib/hosts/tasks.js +15 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugins.js +16 -6
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +54 -0
- package/dist/lib/session/active.js +190 -19
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +111 -28
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.js +19 -7
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +16 -2
- package/dist/lib/session/sync/config.d.ts +1 -15
- package/dist/lib/session/sync/config.js +4 -20
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
package/dist/lib/daemon.js
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from 'fs';
|
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as os from 'os';
|
|
13
13
|
import { getDaemonDir as getDaemonDirRoot } from './state.js';
|
|
14
|
-
import { isAlive, killTree } from './platform/index.js';
|
|
14
|
+
import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
|
|
15
15
|
import { listJobs as listAllJobs } from './routines.js';
|
|
16
16
|
import { JobScheduler } from './scheduler.js';
|
|
17
17
|
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
@@ -127,6 +127,86 @@ export function isDaemonRunning() {
|
|
|
127
127
|
removeDaemonPid();
|
|
128
128
|
return false;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Single-instance claim for the daemon `_run` entrypoint.
|
|
132
|
+
*
|
|
133
|
+
* `agents daemon _run` is reachable directly — a manual invocation, or a
|
|
134
|
+
* service-manager restart that races a still-alive predecessor — bypassing the
|
|
135
|
+
* start lock in startDaemon(). Without this guard runDaemon() would call
|
|
136
|
+
* writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
|
|
137
|
+
* run a second JobScheduler concurrently, so every cron routine fires twice.
|
|
138
|
+
*
|
|
139
|
+
* Returns true and records our PID when no other live daemon owns the pid file;
|
|
140
|
+
* returns false when a live daemon already holds it (the caller must exit
|
|
141
|
+
* without touching any further state). The read-decide-write is serialized
|
|
142
|
+
* behind the same O_EXCL start lock startDaemon() uses, so two _run processes
|
|
143
|
+
* can't both claim in the window between the liveness check and the write.
|
|
144
|
+
*/
|
|
145
|
+
export function claimDaemonInstance() {
|
|
146
|
+
const release = acquireStartLock();
|
|
147
|
+
try {
|
|
148
|
+
const existing = readDaemonPid();
|
|
149
|
+
if (existing !== null && existing !== process.pid && isAlive(existing)) {
|
|
150
|
+
return false; // another live daemon already owns the pid file
|
|
151
|
+
}
|
|
152
|
+
writeDaemonPid(process.pid);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
release?.();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reap stray duplicate daemon processes — a `daemon _run` of THIS install that
|
|
161
|
+
* isn't this process and isn't the pid-file owner. Mirrors the browser orphan
|
|
162
|
+
* reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
|
|
163
|
+
* or a duplicate that lost the pid-file write race, would otherwise keep a
|
|
164
|
+
* second scheduler alive and double-fire jobs even after claimDaemonInstance()
|
|
165
|
+
* hands the pid file to the survivor.
|
|
166
|
+
*
|
|
167
|
+
* Scoped to our own launch entry (process.argv[1]) so it only ever targets
|
|
168
|
+
* daemons of the same installation — a daemon from a different install / home
|
|
169
|
+
* (e.g. a side-by-side dev build, or a test fixture) is a legitimately separate
|
|
170
|
+
* instance and is left untouched. POSIX-only (uses `ps`); a no-op on Windows.
|
|
171
|
+
*/
|
|
172
|
+
export function reapStrayDaemons(keepPid = process.pid) {
|
|
173
|
+
const details = [];
|
|
174
|
+
let reaped = 0;
|
|
175
|
+
if (process.platform === 'win32')
|
|
176
|
+
return { reaped, details };
|
|
177
|
+
const selfEntry = process.argv[1];
|
|
178
|
+
if (!selfEntry)
|
|
179
|
+
return { reaped, details };
|
|
180
|
+
let out;
|
|
181
|
+
try {
|
|
182
|
+
out = execFileSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { reaped, details }; // no `ps` — best effort
|
|
186
|
+
}
|
|
187
|
+
const ownerPid = readDaemonPid();
|
|
188
|
+
for (const line of out.split('\n')) {
|
|
189
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
190
|
+
if (!m)
|
|
191
|
+
continue;
|
|
192
|
+
const pid = parseInt(m[1], 10);
|
|
193
|
+
const args = m[2];
|
|
194
|
+
if (isNaN(pid) || pid === keepPid || pid === process.pid || pid === ownerPid)
|
|
195
|
+
continue;
|
|
196
|
+
// Same install (same launch entry) AND a `daemon _run` command line.
|
|
197
|
+
if (!args.includes(selfEntry))
|
|
198
|
+
continue;
|
|
199
|
+
if (!/\bdaemon\b.*\b_run\b/.test(args))
|
|
200
|
+
continue;
|
|
201
|
+
try {
|
|
202
|
+
process.kill(pid, 'SIGTERM');
|
|
203
|
+
reaped++;
|
|
204
|
+
details.push(`reaped stray daemon pid ${pid}`);
|
|
205
|
+
}
|
|
206
|
+
catch { /* already gone */ }
|
|
207
|
+
}
|
|
208
|
+
return { reaped, details };
|
|
209
|
+
}
|
|
130
210
|
/** Redact values that look like tokens or credentials in a log message. */
|
|
131
211
|
function redactSecrets(message) {
|
|
132
212
|
let safe = message;
|
|
@@ -165,8 +245,30 @@ export function log(level, message) {
|
|
|
165
245
|
}
|
|
166
246
|
/** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
|
|
167
247
|
export async function runDaemon() {
|
|
168
|
-
|
|
248
|
+
// Single-instance guard: a direct `agents daemon _run` (manual, or a
|
|
249
|
+
// service-manager restart racing a live predecessor) must not clobber a
|
|
250
|
+
// running daemon's pid file and start a second scheduler.
|
|
251
|
+
if (!claimDaemonInstance()) {
|
|
252
|
+
const owner = readDaemonPid();
|
|
253
|
+
log('WARN', `Another daemon already owns the pid file (PID: ${owner}); this instance (PID ${process.pid}) is exiting`);
|
|
254
|
+
// Exit cleanly (0) so a service manager treats it as an orderly no-op
|
|
255
|
+
// rather than a failure to restart-flap on.
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
169
258
|
log('INFO', `Daemon started (PID: ${process.pid})`);
|
|
259
|
+
// Reap any stray duplicate daemon of this install that slipped past the start
|
|
260
|
+
// lock or was orphaned by a hard-crash — before it can double-fire jobs.
|
|
261
|
+
try {
|
|
262
|
+
const strays = reapStrayDaemons();
|
|
263
|
+
if (strays.reaped > 0) {
|
|
264
|
+
log('WARN', `Reaped ${strays.reaped} stray daemon process(es)`);
|
|
265
|
+
for (const d of strays.details)
|
|
266
|
+
log('WARN', ` ${d}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
log('ERROR', `Stray daemon reaper failed: ${err.message}`);
|
|
271
|
+
}
|
|
170
272
|
const scheduler = new JobScheduler(async (config) => {
|
|
171
273
|
log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
|
|
172
274
|
try {
|
|
@@ -373,6 +475,23 @@ function xmlEscape(s) {
|
|
|
373
475
|
.replace(/</g, '<')
|
|
374
476
|
.replace(/>/g, '>');
|
|
375
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
480
|
+
*
|
|
481
|
+
* `writeFileSync`'s `mode` is honored only when the file is *created*, so we
|
|
482
|
+
* unlink any pre-existing manifest first. That guarantees every write is a
|
|
483
|
+
* fresh 0600 create — closing the TOCTOU window on new files AND re-locking a
|
|
484
|
+
* stale world-readable manifest left by an older install — since these files
|
|
485
|
+
* embed long-lived credentials.
|
|
486
|
+
*/
|
|
487
|
+
export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
488
|
+
const dir = path.dirname(filePath);
|
|
489
|
+
if (!fs.existsSync(dir)) {
|
|
490
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
491
|
+
}
|
|
492
|
+
fs.rmSync(filePath, { force: true });
|
|
493
|
+
fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
494
|
+
}
|
|
376
495
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
377
496
|
export function generateLaunchdPlist() {
|
|
378
497
|
const agentsBin = getAgentsBinPath();
|
|
@@ -448,6 +567,31 @@ function getAgentsBinPath() {
|
|
|
448
567
|
return 'agents';
|
|
449
568
|
}
|
|
450
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Ask the service manager for the daemon's live PID. Used as a fallback when
|
|
572
|
+
* the daemon hasn't yet written its pid file but launchd/systemd already report
|
|
573
|
+
* it running — so a start never has to surface a null PID for a daemon that is
|
|
574
|
+
* in fact up. Returns null when the service isn't running or the query fails.
|
|
575
|
+
*/
|
|
576
|
+
function readServiceManagerPid(platform = os.platform()) {
|
|
577
|
+
try {
|
|
578
|
+
if (platform === 'linux') {
|
|
579
|
+
const out = execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', SYSTEMD_UNIT], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
580
|
+
const pid = parseInt(out, 10);
|
|
581
|
+
return !isNaN(pid) && pid > 0 ? pid : null;
|
|
582
|
+
}
|
|
583
|
+
if (platform === 'darwin') {
|
|
584
|
+
const out = execFileSync('launchctl', ['list', PLIST_NAME], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
585
|
+
const m = out.match(/"PID"\s*=\s*(\d+)/);
|
|
586
|
+
if (m) {
|
|
587
|
+
const pid = parseInt(m[1], 10);
|
|
588
|
+
return pid > 0 ? pid : null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch { /* not running / manager unavailable */ }
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
451
595
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
452
596
|
export function startDaemon() {
|
|
453
597
|
if (isDaemonRunning()) {
|
|
@@ -467,6 +611,24 @@ export function startDaemon() {
|
|
|
467
611
|
releaseLock();
|
|
468
612
|
}
|
|
469
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Bring the always-on daemon up as a side effect of a background-adjacent
|
|
616
|
+
* command (secrets unlock, browser start, ...), not only from `routines add`.
|
|
617
|
+
*
|
|
618
|
+
* Delegates to the single `startDaemon` entrypoint, so it honors the
|
|
619
|
+
* single-instance start lock and is a no-op when a daemon is already running
|
|
620
|
+
* (returns `already-running`). Best-effort: any failure is swallowed and null
|
|
621
|
+
* returned, so ensuring the daemon can never break the foreground command that
|
|
622
|
+
* happened to bring it up. See issue #415.
|
|
623
|
+
*/
|
|
624
|
+
export function ensureDaemonStarted() {
|
|
625
|
+
try {
|
|
626
|
+
return startDaemon();
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
470
632
|
function startDaemonLocked() {
|
|
471
633
|
const platform = os.platform();
|
|
472
634
|
if (platform === 'darwin') {
|
|
@@ -476,10 +638,9 @@ function startDaemonLocked() {
|
|
|
476
638
|
if (!fs.existsSync(plistDir)) {
|
|
477
639
|
fs.mkdirSync(plistDir, { recursive: true });
|
|
478
640
|
}
|
|
479
|
-
fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
|
|
480
641
|
// The plist may embed a long-lived OAuth token in EnvironmentVariables;
|
|
481
|
-
//
|
|
482
|
-
|
|
642
|
+
// create owner-only atomically (no world-readable window before chmod).
|
|
643
|
+
writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
|
|
483
644
|
try {
|
|
484
645
|
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
485
646
|
}
|
|
@@ -489,7 +650,7 @@ function startDaemonLocked() {
|
|
|
489
650
|
// of success. If no pid materializes within the window, give up on
|
|
490
651
|
// launchd and fall through to a plain detached spawn.
|
|
491
652
|
execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
492
|
-
const pid = waitForPid(3000);
|
|
653
|
+
const pid = waitForPid(3000) ?? readServiceManagerPid();
|
|
493
654
|
if (pid)
|
|
494
655
|
return { pid, method: 'launchd' };
|
|
495
656
|
// launchctl claimed success but nothing ran. Fall through.
|
|
@@ -506,18 +667,21 @@ function startDaemonLocked() {
|
|
|
506
667
|
if (!fs.existsSync(unitDir)) {
|
|
507
668
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
508
669
|
}
|
|
509
|
-
fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
|
|
510
670
|
// May embed a long-lived OAuth token in an Environment= line; owner-only.
|
|
511
|
-
|
|
671
|
+
writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
|
|
512
672
|
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
|
|
513
673
|
execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
514
674
|
execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
515
|
-
const pid = waitForPid(3000);
|
|
516
|
-
|
|
675
|
+
const pid = waitForPid(3000) ?? readServiceManagerPid();
|
|
676
|
+
if (pid)
|
|
677
|
+
return { pid, method: 'systemd' };
|
|
678
|
+
// systemctl returned success but no PID surfaced — fall through to a
|
|
679
|
+
// plain detached spawn rather than reporting a null PID.
|
|
517
680
|
}
|
|
518
681
|
catch {
|
|
519
|
-
|
|
682
|
+
// start threw — fall through to detached spawn
|
|
520
683
|
}
|
|
684
|
+
return startDetached();
|
|
521
685
|
}
|
|
522
686
|
return startDetached();
|
|
523
687
|
}
|
|
@@ -567,17 +731,29 @@ export function startDetached(opts = {}) {
|
|
|
567
731
|
const logPath = opts.logPath ?? getLogPath();
|
|
568
732
|
const logFd = fs.openSync(logPath, 'a');
|
|
569
733
|
const { command, args } = getDaemonLaunch(agentsBin);
|
|
734
|
+
// fdStdio: the log-file fds make windowsHide inert (libuv skips
|
|
735
|
+
// CREATE_NO_WINDOW when a stdio fd is inherited), so on Windows the daemon
|
|
736
|
+
// must DETACH to own no console — otherwise it shares the launcher's console
|
|
737
|
+
// and a console-close event tears it down when the launcher exits (#556).
|
|
570
738
|
const child = spawn(command, args, {
|
|
571
739
|
stdio: ['ignore', logFd, logFd],
|
|
572
|
-
|
|
573
|
-
// No console window on Windows — a hidden, consoleless process can't be
|
|
574
|
-
// torn down by a console-close event when its launcher exits (#556).
|
|
575
|
-
windowsHide: true,
|
|
740
|
+
...backgroundSpawnOptions({ fdStdio: true }),
|
|
576
741
|
env: opts.env ?? buildDetachedDaemonEnv(),
|
|
577
742
|
});
|
|
743
|
+
// A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
|
|
744
|
+
// listener that would crash the parent as an unhandled EventEmitter error.
|
|
745
|
+
// The synchronous `!child.pid` guard below is what reports the failure loudly.
|
|
746
|
+
child.on('error', () => { });
|
|
578
747
|
child.unref();
|
|
579
748
|
fs.closeSync(logFd);
|
|
580
|
-
|
|
749
|
+
// `spawn` leaves `pid` undefined only when the process could not be created.
|
|
750
|
+
// Returning null here (the old `child.pid || null`) let callers report
|
|
751
|
+
// "PID: null" as if the daemon had started — a start with no PID is a failed
|
|
752
|
+
// start, so fail loudly instead of manufacturing a phantom success.
|
|
753
|
+
if (!child.pid) {
|
|
754
|
+
throw new Error(`Failed to start daemon: spawning '${command}' produced no PID (binary missing or not executable?)`);
|
|
755
|
+
}
|
|
756
|
+
return { pid: child.pid, method: 'detached' };
|
|
581
757
|
}
|
|
582
758
|
function waitForPid(timeoutMs) {
|
|
583
759
|
const start = Date.now();
|
|
@@ -58,6 +58,13 @@ export declare function shellForPlatform(platform: DevicePlatform): DeviceShell;
|
|
|
58
58
|
* returning {} would let the next write wipe the user's device list.
|
|
59
59
|
*/
|
|
60
60
|
export declare function loadDevices(): Promise<DeviceRegistry>;
|
|
61
|
+
/**
|
|
62
|
+
* Synchronous {@link loadDevices} for callers already on a sync path (e.g. the
|
|
63
|
+
* `agents sessions --host` fan-out builds its ssh command strings synchronously
|
|
64
|
+
* and only needs the target's platform to pick POSIX vs PowerShell). Same
|
|
65
|
+
* missing-file/corruption contract as {@link loadDevices}.
|
|
66
|
+
*/
|
|
67
|
+
export declare function loadDevicesSync(): DeviceRegistry;
|
|
61
68
|
/** Get a single device profile, or null if it is not registered. */
|
|
62
69
|
export declare function getDevice(name: string): Promise<DeviceProfile | null>;
|
|
63
70
|
/** Fields a caller may supply when creating or updating a device. */
|
|
@@ -117,6 +117,30 @@ export async function loadDevices() {
|
|
|
117
117
|
throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous {@link loadDevices} for callers already on a sync path (e.g. the
|
|
122
|
+
* `agents sessions --host` fan-out builds its ssh command strings synchronously
|
|
123
|
+
* and only needs the target's platform to pick POSIX vs PowerShell). Same
|
|
124
|
+
* missing-file/corruption contract as {@link loadDevices}.
|
|
125
|
+
*/
|
|
126
|
+
export function loadDevicesSync() {
|
|
127
|
+
const p = registryPath();
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = fsSync.readFileSync(p, 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err && err.code === 'ENOENT')
|
|
134
|
+
return {};
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
120
144
|
async function saveDevices(reg) {
|
|
121
145
|
await atomicWriteJson(registryPath(), reg);
|
|
122
146
|
}
|
|
@@ -115,7 +115,7 @@ export function nodeToDeviceInput(node) {
|
|
|
115
115
|
* error when the binary is missing or the daemon is not reachable.
|
|
116
116
|
*/
|
|
117
117
|
export function tailscaleStatusJson() {
|
|
118
|
-
const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8' });
|
|
118
|
+
const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8', windowsHide: true });
|
|
119
119
|
if (res.error && res.error.code === 'ENOENT') {
|
|
120
120
|
throw new Error('tailscale not found on PATH. Install Tailscale, or add devices manually with `agents devices add`.');
|
|
121
121
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared drift-detection internals for `agents doctor` (overview mode) and
|
|
3
|
+
* `agents check` (the scriptable, CI-friendly gate).
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth for "is the install out of sync?": the
|
|
6
|
+
* per-version sync status (fresh / stale / never-synced) and the orphan census.
|
|
7
|
+
* `agents doctor` renders it as a human report; `agents check` reduces it to an
|
|
8
|
+
* exit code. Neither reimplements the diagnostic — both call `computeDrift`.
|
|
9
|
+
*/
|
|
10
|
+
import type { AgentId } from './types.js';
|
|
11
|
+
import { type VersionResourceReport } from './doctor-diff.js';
|
|
12
|
+
export interface SyncStatusRow {
|
|
13
|
+
agent: AgentId;
|
|
14
|
+
version: string;
|
|
15
|
+
status: 'fresh' | 'stale' | 'never-synced';
|
|
16
|
+
isDefault: boolean;
|
|
17
|
+
/** For stale rows: prioritized lines naming exactly what diverged (plugins first). */
|
|
18
|
+
divergence?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface OrphanRow {
|
|
21
|
+
agent: AgentId;
|
|
22
|
+
version: string;
|
|
23
|
+
commands: number;
|
|
24
|
+
skills: number;
|
|
25
|
+
hooks: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function divergenceLines(report: VersionResourceReport): string[];
|
|
28
|
+
export declare function checkSyncStatus(cwd: string): SyncStatusRow[];
|
|
29
|
+
export declare function countOrphans(): OrphanRow[];
|
|
30
|
+
export interface DriftSummary {
|
|
31
|
+
syncRows: SyncStatusRow[];
|
|
32
|
+
orphanRows: OrphanRow[];
|
|
33
|
+
/** Versions whose sources changed since last sync. */
|
|
34
|
+
staleCount: number;
|
|
35
|
+
/** Versions installed but never synced. */
|
|
36
|
+
neverSyncedCount: number;
|
|
37
|
+
/** Versions carrying orphan resources (informational — not a drift signal). */
|
|
38
|
+
orphanVersionCount: number;
|
|
39
|
+
/**
|
|
40
|
+
* True when any installed version is stale or never-synced — the exact signal
|
|
41
|
+
* `agents doctor` surfaces as "run `agents status` to review what has drifted".
|
|
42
|
+
* Orphans are a `prune` concern, not sync drift, so they do NOT set this flag
|
|
43
|
+
* (mirrors the sync-status engine: an orphan alone never flags needsSync).
|
|
44
|
+
*/
|
|
45
|
+
hasDrift: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
|
|
49
|
+
* to a summary with a single `hasDrift` boolean. The gate `agents check` maps
|
|
50
|
+
* to an exit code; the readout `agents doctor` renders in full.
|
|
51
|
+
*/
|
|
52
|
+
export declare function computeDrift(cwd: string): DriftSummary;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ALL_AGENT_IDS } from './agents.js';
|
|
2
|
+
import { getGlobalDefault, listInstalledVersions } from './versions.js';
|
|
3
|
+
import { loadManifest, isStale } from './staleness/index.js';
|
|
4
|
+
import { diffVersionResources } from './doctor-diff.js';
|
|
5
|
+
import { diffVersionCommands, iterCommandsCapableVersions } from './commands.js';
|
|
6
|
+
import { diffVersionSkills, iterSkillsCapableVersions } from './skills.js';
|
|
7
|
+
import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from './hooks.js';
|
|
8
|
+
// Lines naming exactly what's out of sync for a version, plugins prioritized:
|
|
9
|
+
// each divergent plugin gets its own line with specifics (stale mirror version,
|
|
10
|
+
// invalid manifest, or the bundled skills/commands missing from the mirror —
|
|
11
|
+
// the system-repo plugin content that matters most). Other kinds collapse to
|
|
12
|
+
// compact counts so the readout stays scannable.
|
|
13
|
+
export function divergenceLines(report) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
for (const p of report.kinds.plugins) {
|
|
16
|
+
if (p.status === 'missing')
|
|
17
|
+
lines.push(`plugin ${p.name} — not installed`);
|
|
18
|
+
else if (p.status === 'diff')
|
|
19
|
+
lines.push(`plugin ${p.name} — ${p.detail ?? 'mirror drifted'}`);
|
|
20
|
+
}
|
|
21
|
+
for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents']) {
|
|
22
|
+
const rows = report.kinds[kind];
|
|
23
|
+
const miss = rows.filter((r) => r.status === 'missing').length;
|
|
24
|
+
const dif = rows.filter((r) => r.status === 'diff').length;
|
|
25
|
+
const bits = [];
|
|
26
|
+
if (miss)
|
|
27
|
+
bits.push(`${miss} missing`);
|
|
28
|
+
if (dif)
|
|
29
|
+
bits.push(`${dif} drifted`);
|
|
30
|
+
if (bits.length)
|
|
31
|
+
lines.push(`${kind.padEnd(11)} ${bits.join(' · ')}`);
|
|
32
|
+
}
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
export function checkSyncStatus(cwd) {
|
|
36
|
+
const rows = [];
|
|
37
|
+
// Every installed version, not just the default — a stale NON-default version
|
|
38
|
+
// (e.g. one you launched from yesterday) is exactly the rot that silently
|
|
39
|
+
// serves outdated/invalid resources and that `--fix` now heals. Hiding it here
|
|
40
|
+
// is why that class of bug went unnoticed.
|
|
41
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
42
|
+
const def = getGlobalDefault(agent);
|
|
43
|
+
for (const version of listInstalledVersions(agent)) {
|
|
44
|
+
const manifest = loadManifest(agent, version);
|
|
45
|
+
const status = !manifest
|
|
46
|
+
? 'never-synced'
|
|
47
|
+
: isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
|
|
48
|
+
const row = { agent, version, status, isDefault: version === def };
|
|
49
|
+
if (status === 'stale') {
|
|
50
|
+
// Resolve the specifics against non-project layers (the global home is
|
|
51
|
+
// never reconciled against per-cwd project resources).
|
|
52
|
+
const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
|
|
53
|
+
const lines = divergenceLines(report);
|
|
54
|
+
if (lines.length)
|
|
55
|
+
row.divergence = lines;
|
|
56
|
+
}
|
|
57
|
+
rows.push(row);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
export function countOrphans() {
|
|
63
|
+
const byKey = new Map();
|
|
64
|
+
const ensure = (agent, version) => {
|
|
65
|
+
const key = `${agent}@${version}`;
|
|
66
|
+
let row = byKey.get(key);
|
|
67
|
+
if (!row) {
|
|
68
|
+
row = { agent, version, commands: 0, skills: 0, hooks: 0 };
|
|
69
|
+
byKey.set(key, row);
|
|
70
|
+
}
|
|
71
|
+
return row;
|
|
72
|
+
};
|
|
73
|
+
for (const { agent, version } of iterCommandsCapableVersions()) {
|
|
74
|
+
const diff = diffVersionCommands(agent, version);
|
|
75
|
+
if (diff.orphans.length > 0)
|
|
76
|
+
ensure(agent, version).commands = diff.orphans.length;
|
|
77
|
+
}
|
|
78
|
+
for (const { agent, version } of iterSkillsCapableVersions()) {
|
|
79
|
+
const diff = diffVersionSkills(agent, version);
|
|
80
|
+
if (diff.orphans.length > 0)
|
|
81
|
+
ensure(agent, version).skills = diff.orphans.length;
|
|
82
|
+
}
|
|
83
|
+
// Orphan hooks are scripts in the version home that no agents.yaml/hooks.yaml
|
|
84
|
+
// entry registers — so the registrar never wires them to an event and they
|
|
85
|
+
// never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
|
|
86
|
+
// which false-flags valid system-sourced registered hooks.)
|
|
87
|
+
for (const { agent, version } of iterHooksCapableVersions()) {
|
|
88
|
+
const dead = listUnmanagedHooksInVersionHome(agent, version);
|
|
89
|
+
if (dead.length > 0)
|
|
90
|
+
ensure(agent, version).hooks = dead.length;
|
|
91
|
+
}
|
|
92
|
+
return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
|
|
96
|
+
* to a summary with a single `hasDrift` boolean. The gate `agents check` maps
|
|
97
|
+
* to an exit code; the readout `agents doctor` renders in full.
|
|
98
|
+
*/
|
|
99
|
+
export function computeDrift(cwd) {
|
|
100
|
+
const syncRows = checkSyncStatus(cwd);
|
|
101
|
+
const orphanRows = countOrphans();
|
|
102
|
+
const staleCount = syncRows.filter((r) => r.status === 'stale').length;
|
|
103
|
+
const neverSyncedCount = syncRows.filter((r) => r.status === 'never-synced').length;
|
|
104
|
+
return {
|
|
105
|
+
syncRows,
|
|
106
|
+
orphanRows,
|
|
107
|
+
staleCount,
|
|
108
|
+
neverSyncedCount,
|
|
109
|
+
orphanVersionCount: orphanRows.length,
|
|
110
|
+
hasDrift: syncRows.some((r) => r.status !== 'fresh'),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/lib/events.d.ts
CHANGED
package/dist/lib/events.js
CHANGED
|
@@ -16,9 +16,25 @@ import * as path from 'path';
|
|
|
16
16
|
import * as os from 'os';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { parseSshConnection } from './session/provenance.js';
|
|
19
|
+
import { getLogsDir } from './state.js';
|
|
19
20
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
20
|
-
// Logs live under the cache bucket — they're regenerable telemetry.
|
|
21
|
-
|
|
21
|
+
// Logs live under the cache bucket — they're regenerable telemetry. Route
|
|
22
|
+
// through state's canonical home anchor (HOME override → os.homedir()) rather
|
|
23
|
+
// than a bare os.homedir(): on Windows os.homedir() reads USERPROFILE and
|
|
24
|
+
// ignores a HOME override, so a test (or any caller) that redirects HOME would
|
|
25
|
+
// have its events silently written to the real profile instead. state.getLogsDir()
|
|
26
|
+
// honors the HOME override on every platform while falling back to os.homedir()
|
|
27
|
+
// (== USERPROFILE on Windows) in production where HOME is unset.
|
|
28
|
+
//
|
|
29
|
+
// Resolved lazily + memoized: importing this module must NOT call getLogsDir()
|
|
30
|
+
// at eval time. events.ts is pulled in transitively (skills/versions/exec/
|
|
31
|
+
// runner), and several tests mock './state.js' with partial factories that omit
|
|
32
|
+
// getLogsDir — an eager call would crash those on import. Deferring to first use
|
|
33
|
+
// keeps a bare import side-effect-free while staying a one-time resolution.
|
|
34
|
+
let _logsDir;
|
|
35
|
+
function logsDir() {
|
|
36
|
+
return (_logsDir ??= getLogsDir());
|
|
37
|
+
}
|
|
22
38
|
/** Default retention period in days. */
|
|
23
39
|
const DEFAULT_RETENTION_DAYS = 7;
|
|
24
40
|
/** Default max length for truncated strings. */
|
|
@@ -54,16 +70,16 @@ function getLogFilePath(date = new Date()) {
|
|
|
54
70
|
const yyyy = date.getFullYear();
|
|
55
71
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
56
72
|
const dd = String(date.getDate()).padStart(2, '0');
|
|
57
|
-
return path.join(
|
|
73
|
+
return path.join(logsDir(), `events-${yyyy}-${mm}-${dd}.jsonl`);
|
|
58
74
|
}
|
|
59
75
|
function ensureLogsDir() {
|
|
60
|
-
if (!fs.existsSync(
|
|
61
|
-
fs.mkdirSync(
|
|
76
|
+
if (!fs.existsSync(logsDir())) {
|
|
77
|
+
fs.mkdirSync(logsDir(), { recursive: true, mode: DIR_MODE });
|
|
62
78
|
}
|
|
63
79
|
else {
|
|
64
80
|
// Ensure permissions are correct on existing dir
|
|
65
81
|
try {
|
|
66
|
-
fs.chmodSync(
|
|
82
|
+
fs.chmodSync(logsDir(), DIR_MODE);
|
|
67
83
|
}
|
|
68
84
|
catch {
|
|
69
85
|
// May fail if not owner
|
|
@@ -395,10 +411,10 @@ export function emitError(err, payload = {}) {
|
|
|
395
411
|
*/
|
|
396
412
|
export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
397
413
|
try {
|
|
398
|
-
if (!fs.existsSync(
|
|
414
|
+
if (!fs.existsSync(logsDir()))
|
|
399
415
|
return 0;
|
|
400
416
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
401
|
-
const files = fs.readdirSync(
|
|
417
|
+
const files = fs.readdirSync(logsDir()).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
|
|
402
418
|
let removed = 0;
|
|
403
419
|
for (const file of files) {
|
|
404
420
|
const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
|
|
@@ -407,7 +423,7 @@ export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
|
407
423
|
const [, yyyy, mm, dd] = match;
|
|
408
424
|
const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
|
|
409
425
|
if (fileDate.getTime() < cutoff) {
|
|
410
|
-
fs.unlinkSync(path.join(
|
|
426
|
+
fs.unlinkSync(path.join(logsDir(), file));
|
|
411
427
|
removed++;
|
|
412
428
|
}
|
|
413
429
|
}
|
|
@@ -439,9 +455,9 @@ export function maybeRotate() {
|
|
|
439
455
|
export function query(options) {
|
|
440
456
|
const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
|
|
441
457
|
const results = [];
|
|
442
|
-
if (!fs.existsSync(
|
|
458
|
+
if (!fs.existsSync(logsDir()))
|
|
443
459
|
return results;
|
|
444
|
-
const files = fs.readdirSync(
|
|
460
|
+
const files = fs.readdirSync(logsDir())
|
|
445
461
|
.filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
|
|
446
462
|
.sort()
|
|
447
463
|
.reverse();
|
|
@@ -467,7 +483,7 @@ export function query(options) {
|
|
|
467
483
|
continue;
|
|
468
484
|
if (endDay && fileDate > endDay)
|
|
469
485
|
continue;
|
|
470
|
-
const content = fs.readFileSync(path.join(
|
|
486
|
+
const content = fs.readFileSync(path.join(logsDir(), file), 'utf-8');
|
|
471
487
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
472
488
|
for (const line of lines.reverse()) {
|
|
473
489
|
try {
|
|
@@ -528,4 +544,6 @@ export function getTimingStats(label, options = {}) {
|
|
|
528
544
|
};
|
|
529
545
|
}
|
|
530
546
|
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
531
|
-
export
|
|
547
|
+
export function getLogsPath() {
|
|
548
|
+
return logsDir();
|
|
549
|
+
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -230,6 +230,12 @@ export interface FallbackEntry {
|
|
|
230
230
|
agent: AgentId;
|
|
231
231
|
/** Optional pinned version (e.g. '0.116.0'). When set, takes precedence over the active default. */
|
|
232
232
|
version?: string;
|
|
233
|
+
/**
|
|
234
|
+
* Env vars merged over options.env for THIS attempt only. Used by profiles
|
|
235
|
+
* with `fallback_model` to swap the model env key (e.g. ANTHROPIC_MODEL) on
|
|
236
|
+
* a same-agent retry without touching auth or base URL.
|
|
237
|
+
*/
|
|
238
|
+
envOverride?: Record<string, string>;
|
|
233
239
|
}
|
|
234
240
|
/** ExecOptions extended with a fallback chain for rate-limit cascading. */
|
|
235
241
|
export interface FallbackOptions extends ExecOptions {
|
|
@@ -237,6 +243,17 @@ export interface FallbackOptions extends ExecOptions {
|
|
|
237
243
|
fallback: FallbackEntry[];
|
|
238
244
|
/** Fallback requires a prompt -- chain handoff doesn't apply to interactive sessions. */
|
|
239
245
|
prompt: string;
|
|
246
|
+
/**
|
|
247
|
+
* Optional out-param the caller reads AFTER the call to learn which chain
|
|
248
|
+
* entry actually executed — updated to each entry as it is attempted, so on
|
|
249
|
+
* return it holds the agent+version whose exit code is returned. Lets the
|
|
250
|
+
* audit log record the fallback that really ran, not always the primary
|
|
251
|
+
* (issue #347).
|
|
252
|
+
*/
|
|
253
|
+
dispatchSink?: {
|
|
254
|
+
agent?: AgentId;
|
|
255
|
+
version?: string;
|
|
256
|
+
};
|
|
240
257
|
}
|
|
241
258
|
/**
|
|
242
259
|
* Build the prompt handed to the fallback agent when the primary was stopped
|