@phnx-labs/agents-cli 1.20.34 → 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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- 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-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- 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 +196 -91
- 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.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- 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/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- 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/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- 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 +183 -23
- 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 +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- 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 +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- 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 +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- 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 +22 -0
- package/dist/lib/hosts/tasks.js +29 -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/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- 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/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- 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 +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- 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 +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -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/registry.js +25 -9
- 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/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- 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/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- 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/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -11,7 +11,7 @@ import { connectSSH, shellQuote } from './drivers/ssh.js';
|
|
|
11
11
|
import { clearProfileRuntime } from './runtime-state.js';
|
|
12
12
|
import { resolveDomainSkill } from './domain-skills.js';
|
|
13
13
|
import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
|
|
14
|
-
import { getRefs, resolveRefToCoords } from './refs.js';
|
|
14
|
+
import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
|
|
15
15
|
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
16
16
|
import { typeEditorText } from './editor.js';
|
|
17
17
|
import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
|
|
@@ -412,12 +412,23 @@ export class BrowserService {
|
|
|
412
412
|
return this.stop(taskName);
|
|
413
413
|
}
|
|
414
414
|
async stopProfile(profileName) {
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
// Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
|
|
416
|
+
// but callers pass the bare profile name (or, occasionally, an exact composite).
|
|
417
|
+
// A plain `connections.get(profileName)` therefore missed every real remote
|
|
418
|
+
// connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
|
|
419
|
+
// Windows, the WMI-spawned browser orphaned on the remote host after every
|
|
420
|
+
// `browser stop --profile` (#559). Match the exact key AND every
|
|
421
|
+
// `<profileName>@<endpoint>` composite so all of a profile's live connections
|
|
422
|
+
// are torn down.
|
|
423
|
+
const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
|
|
424
|
+
for (const key of keys) {
|
|
425
|
+
const conn = this.connections.get(key);
|
|
426
|
+
if (!conn)
|
|
427
|
+
continue;
|
|
417
428
|
conn.cdp.close();
|
|
418
429
|
killChrome(conn.pid);
|
|
419
430
|
conn.cleanup?.();
|
|
420
|
-
this.connections.delete(
|
|
431
|
+
this.connections.delete(key);
|
|
421
432
|
}
|
|
422
433
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
423
434
|
const pidFile = path.join(runtimeDir, 'pid');
|
|
@@ -661,6 +672,31 @@ export class BrowserService {
|
|
|
661
672
|
{ width: 0, height: 0 };
|
|
662
673
|
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
663
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Export the current tab as a PDF via CDP `Page.printToPDF`. Reuses the
|
|
677
|
+
* screenshot session + tab resolution so `--tab`, path sandboxing, and the
|
|
678
|
+
* auto-path (`sessions/<task>/<ts>.pdf`) all behave identically to
|
|
679
|
+
* `screenshot`. `printBackground: true` matches Chrome's default print flow
|
|
680
|
+
* — without it, dark-mode pages render on a blank sheet.
|
|
681
|
+
*/
|
|
682
|
+
async printToPdf(taskId, tabHint, outputPath) {
|
|
683
|
+
const { conn, task } = await this.findTask(taskId);
|
|
684
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
685
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
686
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
687
|
+
if (!target) {
|
|
688
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
689
|
+
}
|
|
690
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
691
|
+
const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
|
|
692
|
+
const buffer = Buffer.from(data, 'base64');
|
|
693
|
+
const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
|
|
694
|
+
const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
|
|
695
|
+
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
696
|
+
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
697
|
+
await fs.promises.writeFile(finalPath, buffer);
|
|
698
|
+
return { path: finalPath, bytes: buffer.length };
|
|
699
|
+
}
|
|
664
700
|
// ─── Recording ──────────────────────────────────────────────────────────────
|
|
665
701
|
//
|
|
666
702
|
// CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
|
|
@@ -693,17 +729,25 @@ export class BrowserService {
|
|
|
693
729
|
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
694
730
|
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
695
731
|
const { spawn } = await import('child_process');
|
|
732
|
+
// CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
|
|
733
|
+
// are event-driven, not clocked). Feeding those into a fixed `-framerate`
|
|
734
|
+
// input made ffmpeg assume every frame was 1/fps apart, so a page that
|
|
735
|
+
// painted slower/faster than `fps` played back at the wrong speed. Instead
|
|
736
|
+
// stamp each frame with its wall-clock arrival time
|
|
737
|
+
// (`-use_wallclock_as_timestamps 1`) and keep those timings on output
|
|
738
|
+
// (`-vsync vfr`), so playback duration matches the real capture.
|
|
696
739
|
const ffmpeg = spawn('ffmpeg', [
|
|
697
740
|
'-loglevel', 'error',
|
|
698
741
|
'-f', 'image2pipe',
|
|
699
|
-
'-
|
|
742
|
+
'-use_wallclock_as_timestamps', '1',
|
|
700
743
|
'-i', '-',
|
|
701
744
|
'-c:v', 'libvpx-vp9',
|
|
702
745
|
'-b:v', '1M',
|
|
703
746
|
'-pix_fmt', 'yuv420p',
|
|
747
|
+
'-vsync', 'vfr',
|
|
704
748
|
'-y',
|
|
705
749
|
outputPath,
|
|
706
|
-
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
750
|
+
], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
|
707
751
|
// Wait for the spawn to confirm (or fail) before we wire CDP frames into a
|
|
708
752
|
// dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
|
|
709
753
|
// instead of a silently empty .webm.
|
|
@@ -724,9 +768,16 @@ export class BrowserService {
|
|
|
724
768
|
ffmpeg.once('error', onError);
|
|
725
769
|
ffmpeg.once('spawn', onSpawn);
|
|
726
770
|
});
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
ffmpeg
|
|
771
|
+
// Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
|
|
772
|
+
// failing encode is DIAGNOSABLE at recordStop instead of being discarded.
|
|
773
|
+
// Cap the buffer so a chatty ffmpeg can't grow it unbounded.
|
|
774
|
+
let stderrBuf = '';
|
|
775
|
+
ffmpeg.stderr?.on('data', (d) => {
|
|
776
|
+
stderrBuf += d.toString();
|
|
777
|
+
if (stderrBuf.length > 64 * 1024)
|
|
778
|
+
stderrBuf = stderrBuf.slice(-64 * 1024);
|
|
779
|
+
});
|
|
780
|
+
const ffmpegStderr = () => stderrBuf;
|
|
730
781
|
ffmpeg.on('error', () => { });
|
|
731
782
|
// 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
|
|
732
783
|
const everyNthFrame = Math.max(1, Math.round(30 / fps));
|
|
@@ -752,6 +803,7 @@ export class BrowserService {
|
|
|
752
803
|
durationMs,
|
|
753
804
|
maxBytes,
|
|
754
805
|
ffmpeg,
|
|
806
|
+
ffmpegStderr,
|
|
755
807
|
sessionId,
|
|
756
808
|
conn,
|
|
757
809
|
frameHandler,
|
|
@@ -795,27 +847,56 @@ export class BrowserService {
|
|
|
795
847
|
catch {
|
|
796
848
|
// session may already be gone
|
|
797
849
|
}
|
|
798
|
-
// Close ffmpeg stdin so it flushes the output file cleanly
|
|
799
|
-
|
|
800
|
-
|
|
850
|
+
// Close ffmpeg stdin so it flushes the output file cleanly, and observe how
|
|
851
|
+
// it exits. A non-zero exit means the encode failed — the recording must
|
|
852
|
+
// NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
|
|
853
|
+
// the promise, which leaked the process) and treat the recording as failed.
|
|
854
|
+
const finalize = await new Promise((resolve) => {
|
|
855
|
+
let done = false;
|
|
856
|
+
const settle = (r) => {
|
|
857
|
+
if (done)
|
|
858
|
+
return;
|
|
859
|
+
done = true;
|
|
860
|
+
clearTimeout(timer);
|
|
861
|
+
resolve(r);
|
|
862
|
+
};
|
|
863
|
+
const timer = setTimeout(() => {
|
|
864
|
+
try {
|
|
865
|
+
rec.ffmpeg.kill('SIGKILL');
|
|
866
|
+
}
|
|
867
|
+
catch { /* already gone */ }
|
|
868
|
+
settle({ code: null, timedOut: true });
|
|
869
|
+
}, 5000);
|
|
870
|
+
rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
|
|
801
871
|
try {
|
|
802
872
|
rec.ffmpeg.stdin?.end();
|
|
803
873
|
}
|
|
804
874
|
catch {
|
|
805
|
-
|
|
875
|
+
// stdin already closed; wait for exit or the hard timeout above.
|
|
806
876
|
}
|
|
807
|
-
setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
|
|
808
877
|
});
|
|
878
|
+
const durationMs = Date.now() - rec.startedAt;
|
|
879
|
+
// Drop the recording from the map before any throw, so a failed stop
|
|
880
|
+
// doesn't wedge the task in a permanent "already recording" state.
|
|
881
|
+
this.recordings.delete(taskId);
|
|
882
|
+
if (finalize.timedOut) {
|
|
883
|
+
throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
|
|
884
|
+
`The recording at ${rec.outputPath} is incomplete.`);
|
|
885
|
+
}
|
|
886
|
+
if (finalize.code !== 0) {
|
|
887
|
+
const err = rec.ffmpegStderr().trim();
|
|
888
|
+
throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
|
|
889
|
+
`${rec.outputPath}; the file is likely corrupt or empty.` +
|
|
890
|
+
(err ? ` ffmpeg: ${err.slice(-800)}` : ''));
|
|
891
|
+
}
|
|
809
892
|
let bytes = 0;
|
|
810
893
|
try {
|
|
811
894
|
const st = await fs.promises.stat(rec.outputPath);
|
|
812
895
|
bytes = st.size;
|
|
813
896
|
}
|
|
814
897
|
catch {
|
|
815
|
-
// ffmpeg
|
|
898
|
+
// ffmpeg exited 0 but the file is missing — still a failed recording.
|
|
816
899
|
}
|
|
817
|
-
const durationMs = Date.now() - rec.startedAt;
|
|
818
|
-
this.recordings.delete(taskId);
|
|
819
900
|
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
820
901
|
}
|
|
821
902
|
async recordStatus(taskId) {
|
|
@@ -824,7 +905,6 @@ export class BrowserService {
|
|
|
824
905
|
return { recording: false };
|
|
825
906
|
return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
|
|
826
907
|
}
|
|
827
|
-
refsCache = new Map();
|
|
828
908
|
async refs(taskId, tabHint, opts = {}) {
|
|
829
909
|
const { conn, task } = await this.findTask(taskId);
|
|
830
910
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
@@ -833,7 +913,51 @@ export class BrowserService {
|
|
|
833
913
|
if (!target)
|
|
834
914
|
throw new Error(`Tab ${shortId} not found`);
|
|
835
915
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
836
|
-
|
|
916
|
+
const result = await getRefs(conn.cdp, sessionId, opts);
|
|
917
|
+
// Snapshot the stable descriptors AND the opts they were numbered against
|
|
918
|
+
// so a later click/type can self-heal a drifted ref by rebuilding with the
|
|
919
|
+
// same filter. `refs()` is the sole owner of this cache — actions read it,
|
|
920
|
+
// never overwrite it. Persist to tasks.json so it survives a daemon
|
|
921
|
+
// restart.
|
|
922
|
+
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
923
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
924
|
+
return { refs: result.refs, nodeMap: result.nodeMap };
|
|
925
|
+
}
|
|
926
|
+
/** Record the last ref listing (descriptors + opts) for a tab into state. */
|
|
927
|
+
cacheRefDescriptors(task, shortId, nodeMap, opts) {
|
|
928
|
+
if (!task.refDescriptors)
|
|
929
|
+
task.refDescriptors = {};
|
|
930
|
+
task.refDescriptors[shortId] = { descriptors: describeRefs(nodeMap), opts };
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Re-resolve a caller-supplied ref against a freshly-built node map, healing
|
|
934
|
+
* it back to the right element when the integer ref has drifted since the
|
|
935
|
+
* cached `refs` listing. Shared by `click` and `type` so both self-heal
|
|
936
|
+
* identically. Returns the ref to act on plus, when a heal occurred, the
|
|
937
|
+
* {@link HealInfo} to surface. Throws when the cached element is gone.
|
|
938
|
+
*/
|
|
939
|
+
resolveHealedRef(snapshot, nodeMap, ref) {
|
|
940
|
+
const cached = snapshot?.descriptors.find((d) => d.ref === ref);
|
|
941
|
+
if (!cached)
|
|
942
|
+
return { targetRef: ref };
|
|
943
|
+
const fresh = nodeMap.get(ref);
|
|
944
|
+
const stillMatches = fresh !== undefined &&
|
|
945
|
+
fresh.role === cached.role &&
|
|
946
|
+
fresh.name === cached.name &&
|
|
947
|
+
fresh.backendNodeId !== undefined;
|
|
948
|
+
if (stillMatches)
|
|
949
|
+
return { targetRef: ref };
|
|
950
|
+
const newRef = healRef(cached, nodeMap);
|
|
951
|
+
if (newRef === null) {
|
|
952
|
+
throw new Error(`Ref ${ref} (${cached.role} "${cached.name}") could not be re-resolved: ` +
|
|
953
|
+
`no matching element on the current page. Re-run 'browser refs' to ` +
|
|
954
|
+
`refresh the ref numbers, or act by position with 'browser click --at X,Y'.`);
|
|
955
|
+
}
|
|
956
|
+
if (newRef === ref)
|
|
957
|
+
return { targetRef: ref };
|
|
958
|
+
console.error(`[browser] self-healed ref ${ref} -> ${newRef} (${cached.role} "${cached.name}") — ` +
|
|
959
|
+
`cached descriptor re-matched after the ref drifted`);
|
|
960
|
+
return { targetRef: newRef, healed: { from: ref, to: newRef, role: cached.role, name: cached.name } };
|
|
837
961
|
}
|
|
838
962
|
async click(taskId, ref, tabHint) {
|
|
839
963
|
const { conn, task } = await this.findTask(taskId);
|
|
@@ -843,8 +967,38 @@ export class BrowserService {
|
|
|
843
967
|
if (!target)
|
|
844
968
|
throw new Error(`Tab ${shortId} not found`);
|
|
845
969
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
846
|
-
|
|
847
|
-
|
|
970
|
+
// Rebuild the node map with the SAME opts the cached listing was numbered
|
|
971
|
+
// against, so the caller's ref lands on the element they saw in `browser
|
|
972
|
+
// refs`. Rebuilding with a different filter (the old interactive:false)
|
|
973
|
+
// renumbers every ref and defeats self-heal on the second click. Default
|
|
974
|
+
// to the user-facing interactive numbering when no listing was cached yet.
|
|
975
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
976
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
977
|
+
const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
|
|
978
|
+
// Self-heal: the integer ref is positional and drifts on re-render. If the
|
|
979
|
+
// fresh node at this position no longer matches the cached descriptor,
|
|
980
|
+
// re-resolve (by attrs/proximity-tie-broken role+name) BEFORE clicking the
|
|
981
|
+
// wrong element. The cache is owned by refs() and NOT rewritten here — the
|
|
982
|
+
// caller's ref numbers stay anchored to the listing they came from.
|
|
983
|
+
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
984
|
+
const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, targetRef);
|
|
985
|
+
await clickAtCoords(conn.cdp, sessionId, x, y);
|
|
986
|
+
return healed ? { healed } : {};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Click raw viewport coordinates, bypassing ref resolution entirely. Backs
|
|
990
|
+
* `browser click --at X,Y` — the escape hatch when the accessibility tree
|
|
991
|
+
* exposes no usable ref (canvas apps, custom-drawn UI) and the caller has
|
|
992
|
+
* located the target from a screenshot.
|
|
993
|
+
*/
|
|
994
|
+
async clickAt(taskId, x, y, tabHint) {
|
|
995
|
+
const { conn, task } = await this.findTask(taskId);
|
|
996
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
997
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
998
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
999
|
+
if (!target)
|
|
1000
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
1001
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
848
1002
|
await clickAtCoords(conn.cdp, sessionId, x, y);
|
|
849
1003
|
}
|
|
850
1004
|
async type(taskId, ref, text, tabHint, clear) {
|
|
@@ -855,8 +1009,14 @@ export class BrowserService {
|
|
|
855
1009
|
if (!target)
|
|
856
1010
|
throw new Error(`Tab ${shortId} not found`);
|
|
857
1011
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
858
|
-
|
|
859
|
-
|
|
1012
|
+
// Same self-healing story as click(): rebuild against the cached listing's
|
|
1013
|
+
// opts so refs line up with what the user saw, then heal a drifted ref
|
|
1014
|
+
// before typing into the wrong field.
|
|
1015
|
+
const snapshot = task.refDescriptors?.[shortId];
|
|
1016
|
+
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1017
|
+
const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
|
|
1018
|
+
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1019
|
+
const node = nodeMap.get(targetRef);
|
|
860
1020
|
if (!node)
|
|
861
1021
|
throw new Error(`Ref ${ref} not found`);
|
|
862
1022
|
if (node.editor) {
|
|
@@ -78,6 +78,15 @@ export interface Task {
|
|
|
78
78
|
currentTabId?: string;
|
|
79
79
|
createdAt: number;
|
|
80
80
|
pid: number;
|
|
81
|
+
/**
|
|
82
|
+
* Per-tab snapshot of the last ref listing captured for that tab
|
|
83
|
+
* (shortId -> {descriptors, opts}). Persisted to tasks.json so a later
|
|
84
|
+
* `click`/`type <ref>` can self-heal a drifted ref — the cached `opts` let
|
|
85
|
+
* the action rebuild its node map with the SAME accessibility filter the
|
|
86
|
+
* listing was numbered against. Owned by `refs()`; actions never overwrite
|
|
87
|
+
* it. See RefSnapshot / RefDescriptor.
|
|
88
|
+
*/
|
|
89
|
+
refDescriptors?: Record<string, import('./refs.js').RefSnapshot>;
|
|
81
90
|
}
|
|
82
91
|
export interface TabInfo {
|
|
83
92
|
id: string;
|
|
@@ -118,7 +127,7 @@ export interface HistoricalTask {
|
|
|
118
127
|
domains: string[];
|
|
119
128
|
tabCount: number;
|
|
120
129
|
}
|
|
121
|
-
export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
|
|
130
|
+
export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
|
|
122
131
|
export interface IPCRequest {
|
|
123
132
|
action: IPCAction;
|
|
124
133
|
task?: string;
|
|
@@ -129,6 +138,8 @@ export interface IPCRequest {
|
|
|
129
138
|
expr?: string;
|
|
130
139
|
path?: string;
|
|
131
140
|
ref?: number;
|
|
141
|
+
atX?: number;
|
|
142
|
+
atY?: number;
|
|
132
143
|
text?: string;
|
|
133
144
|
key?: string;
|
|
134
145
|
scrollX?: number;
|
|
@@ -194,6 +205,8 @@ export interface IPCResponse {
|
|
|
194
205
|
height?: number;
|
|
195
206
|
refs?: string;
|
|
196
207
|
nodes?: RefNodeJson[];
|
|
208
|
+
/** Human-readable note surfaced to the CLI (e.g. a self-heal notice on click). */
|
|
209
|
+
message?: string;
|
|
197
210
|
port?: number;
|
|
198
211
|
pid?: number;
|
|
199
212
|
fps?: number;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live budget kill-switch for `agents cloud` dispatch (issue #399).
|
|
3
|
+
*
|
|
4
|
+
* Follow-up to #346, which shipped a pre-flight budget gate for cloud runs but
|
|
5
|
+
* no live mid-run enforcement — a runaway cloud task would keep spending until
|
|
6
|
+
* the user hit Ctrl-C. This wires the same `makeLiveSpendWatcher` the local
|
|
7
|
+
* headless run uses (src/lib/exec.ts) into the cloud SSE stream: each `usage`
|
|
8
|
+
* event from the provider feeds the watcher, and on breach we call
|
|
9
|
+
* `provider.cancel(taskId)` to terminate server-side.
|
|
10
|
+
*
|
|
11
|
+
* Wrapper shape mirrors `renderStream`'s input — it takes an
|
|
12
|
+
* `AsyncIterable<CloudEvent>` and yields the same events downstream. Dormant
|
|
13
|
+
* (returns the source stream unchanged) when no caps are configured.
|
|
14
|
+
*/
|
|
15
|
+
import type { CloudProvider, CloudEvent } from '../cloud/types.js';
|
|
16
|
+
import { type BreachInfo } from './enforce.js';
|
|
17
|
+
/** Result surface exposed to the caller so it can act on a mid-stream breach. */
|
|
18
|
+
export interface CloudBudgetGate {
|
|
19
|
+
/** True once a cap crossed and cancel() was invoked. */
|
|
20
|
+
breached(): boolean;
|
|
21
|
+
breach(): BreachInfo | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a cloud event stream with a live budget watcher. On a `usage` event we
|
|
25
|
+
* feed the shared watcher; on the first breach we call `provider.cancel(taskId)`
|
|
26
|
+
* and forward a synthetic `status:'cancelled'` + `error` frame so the renderer
|
|
27
|
+
* shows why the task stopped. Returns null when no caps are configured; callers
|
|
28
|
+
* fall through to the raw stream in that case.
|
|
29
|
+
*/
|
|
30
|
+
export declare function wrapStreamWithBudgetGate(args: {
|
|
31
|
+
provider: CloudProvider;
|
|
32
|
+
taskId: string;
|
|
33
|
+
/** Project attribution key — repo slug for Rush, or cwd. */
|
|
34
|
+
project: string;
|
|
35
|
+
/** Agent the dispatch runs under (for per_agent cap accounting). */
|
|
36
|
+
agent: string;
|
|
37
|
+
/** cwd used to resolve the effective budget config. */
|
|
38
|
+
cwd?: string;
|
|
39
|
+
}): {
|
|
40
|
+
wrap: (source: AsyncIterable<CloudEvent>) => AsyncIterable<CloudEvent>;
|
|
41
|
+
gate: CloudBudgetGate;
|
|
42
|
+
} | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { capsFromConfig, makeLiveSpendWatcher, } from './enforce.js';
|
|
2
|
+
import { resolveBudgetConfig, hasAnyCap } from './config.js';
|
|
3
|
+
import { loadLedger, localDay, spendForDay, spendForProject } from './ledger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Wrap a cloud event stream with a live budget watcher. On a `usage` event we
|
|
6
|
+
* feed the shared watcher; on the first breach we call `provider.cancel(taskId)`
|
|
7
|
+
* and forward a synthetic `status:'cancelled'` + `error` frame so the renderer
|
|
8
|
+
* shows why the task stopped. Returns null when no caps are configured; callers
|
|
9
|
+
* fall through to the raw stream in that case.
|
|
10
|
+
*/
|
|
11
|
+
export function wrapStreamWithBudgetGate(args) {
|
|
12
|
+
const cfg = resolveBudgetConfig(args.cwd);
|
|
13
|
+
if (!hasAnyCap(cfg))
|
|
14
|
+
return null;
|
|
15
|
+
const today = localDay();
|
|
16
|
+
const entries = loadLedger();
|
|
17
|
+
const caps = capsFromConfig(cfg, {
|
|
18
|
+
daySpend: spendForDay(today, entries),
|
|
19
|
+
projectSpend: spendForProject(args.project, entries),
|
|
20
|
+
});
|
|
21
|
+
let firstBreach = null;
|
|
22
|
+
const watcher = makeLiveSpendWatcher({
|
|
23
|
+
caps,
|
|
24
|
+
onBreach: (b) => {
|
|
25
|
+
firstBreach = b;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
async function* wrap(source) {
|
|
29
|
+
try {
|
|
30
|
+
for await (const event of source) {
|
|
31
|
+
if (event.type === 'usage') {
|
|
32
|
+
watcher.feedUsage({
|
|
33
|
+
agent: args.agent,
|
|
34
|
+
model: event.model,
|
|
35
|
+
inputTokens: event.inputTokens ?? 0,
|
|
36
|
+
outputTokens: event.outputTokens ?? 0,
|
|
37
|
+
});
|
|
38
|
+
if (watcher.breached() && firstBreach) {
|
|
39
|
+
// Cancel server-side FIRST so we stop the meter; then surface the
|
|
40
|
+
// breach to the renderer as an error + cancelled status so the CLI
|
|
41
|
+
// exits with a visible reason (not a silent stream close).
|
|
42
|
+
try {
|
|
43
|
+
await args.provider.cancel(args.taskId);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// Best-effort — even if cancel fails, break the stream: the
|
|
47
|
+
// caller sees the error frame and can retry manually.
|
|
48
|
+
yield {
|
|
49
|
+
type: 'error',
|
|
50
|
+
message: `[budget] cap ${firstBreach.cap} exceeded ($${firstBreach.spend.toFixed(2)} > $${firstBreach.limit.toFixed(2)}); cancel FAILED: ${err.message}`,
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
yield { type: 'status', status: 'cancelled', timestamp: new Date().toISOString() };
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
yield {
|
|
57
|
+
type: 'error',
|
|
58
|
+
message: `[budget] cap ${firstBreach.cap} exceeded ($${firstBreach.spend.toFixed(2)} > $${firstBreach.limit.toFixed(2)}) — cancelled cloud task ${args.taskId}`,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
yield { type: 'status', status: 'cancelled', timestamp: new Date().toISOString() };
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
yield event;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
watcher.dispose();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
wrap,
|
|
74
|
+
gate: {
|
|
75
|
+
breached: () => watcher.breached(),
|
|
76
|
+
breach: () => firstBreach,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentManager } from '../teams/agents.js';
|
|
2
|
+
import { type BreachInfo } from './enforce.js';
|
|
3
|
+
/** Public surface of the team-scoped budget watcher. */
|
|
4
|
+
export interface TeamBudgetWatcher {
|
|
5
|
+
/** Feed any new usage events from every running teammate's stdout.log. */
|
|
6
|
+
poll(): Promise<void>;
|
|
7
|
+
/** True once any cap has been crossed (mirrors LiveSpendWatcher). */
|
|
8
|
+
breached(): boolean;
|
|
9
|
+
/** The first breach seen, or null if none yet. */
|
|
10
|
+
breach(): BreachInfo | null;
|
|
11
|
+
/** Release references and stop tapping streams. Idempotent. */
|
|
12
|
+
dispose(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build a team-scoped budget watcher for the given team. Returns null when the
|
|
16
|
+
* effective budget config has no caps — matching the local watcher's dormant
|
|
17
|
+
* contract so callers pay nothing when the feature is off.
|
|
18
|
+
*
|
|
19
|
+
* The watcher is seeded with prior-day and prior-project spend from the ledger
|
|
20
|
+
* so a partway-through day counts today's earlier runs against `per_day`.
|
|
21
|
+
* Each `poll()` reads new bytes from every running teammate's stdout.log and
|
|
22
|
+
* feeds parsed usage events into the shared watcher — `onBreach` fires exactly
|
|
23
|
+
* once, mirroring `makeLiveSpendWatcher`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createTeamBudgetWatcher(args: {
|
|
26
|
+
manager: AgentManager;
|
|
27
|
+
team: string;
|
|
28
|
+
/** Project/cwd used to (a) resolve budget config and (b) seed project spend. */
|
|
29
|
+
cwd: string;
|
|
30
|
+
onBreach: (breach: BreachInfo) => void;
|
|
31
|
+
}): TeamBudgetWatcher | null;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live budget kill-switch for `agents teams` supervisor (issue #399).
|
|
3
|
+
*
|
|
4
|
+
* Follow-up to #346, which wired `makeLiveSpendWatcher` into local `agents run`
|
|
5
|
+
* only. Teams spawns each teammate as its own `agents run --headless --json`
|
|
6
|
+
* process, so per-teammate `per_run` caps already fire from those child
|
|
7
|
+
* watchers. What was still missing was aggregate cross-teammate enforcement
|
|
8
|
+
* (`per_project` / `per_day` / `per_agent` combined), because the ledger only
|
|
9
|
+
* gets written on child close — a long-running teammate would blow the shared
|
|
10
|
+
* cap without any single child watcher noticing.
|
|
11
|
+
*
|
|
12
|
+
* This module reuses `makeLiveSpendWatcher` verbatim: on each supervisor wave
|
|
13
|
+
* we tail every running teammate's stdout.log through `extractUsageEvents`
|
|
14
|
+
* (the same primitive the local run path uses in src/lib/exec.ts), feed the
|
|
15
|
+
* usage events into ONE shared watcher, and expose `breached()` so the
|
|
16
|
+
* supervisor can call `stopByTask` to terminate the whole team.
|
|
17
|
+
*
|
|
18
|
+
* Dormant (returns null) when no caps are configured — same zero-cost contract
|
|
19
|
+
* as the local watcher.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import { capsFromConfig, extractUsageEvents, makeLiveSpendWatcher, } from './enforce.js';
|
|
23
|
+
import { resolveBudgetConfig, hasAnyCap } from './config.js';
|
|
24
|
+
import { loadLedger, localDay, spendForDay, spendForProject } from './ledger.js';
|
|
25
|
+
/**
|
|
26
|
+
* Build a team-scoped budget watcher for the given team. Returns null when the
|
|
27
|
+
* effective budget config has no caps — matching the local watcher's dormant
|
|
28
|
+
* contract so callers pay nothing when the feature is off.
|
|
29
|
+
*
|
|
30
|
+
* The watcher is seeded with prior-day and prior-project spend from the ledger
|
|
31
|
+
* so a partway-through day counts today's earlier runs against `per_day`.
|
|
32
|
+
* Each `poll()` reads new bytes from every running teammate's stdout.log and
|
|
33
|
+
* feeds parsed usage events into the shared watcher — `onBreach` fires exactly
|
|
34
|
+
* once, mirroring `makeLiveSpendWatcher`.
|
|
35
|
+
*/
|
|
36
|
+
export function createTeamBudgetWatcher(args) {
|
|
37
|
+
const cfg = resolveBudgetConfig(args.cwd);
|
|
38
|
+
if (!hasAnyCap(cfg))
|
|
39
|
+
return null;
|
|
40
|
+
const today = localDay();
|
|
41
|
+
const entries = loadLedger();
|
|
42
|
+
const caps = capsFromConfig(cfg, {
|
|
43
|
+
daySpend: spendForDay(today, entries),
|
|
44
|
+
projectSpend: spendForProject(args.cwd, entries),
|
|
45
|
+
});
|
|
46
|
+
let firstBreach = null;
|
|
47
|
+
const watcher = makeLiveSpendWatcher({
|
|
48
|
+
caps,
|
|
49
|
+
onBreach: (b) => {
|
|
50
|
+
firstBreach = b;
|
|
51
|
+
args.onBreach(b);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const cursors = new Map();
|
|
55
|
+
let disposed = false;
|
|
56
|
+
return {
|
|
57
|
+
async poll() {
|
|
58
|
+
if (disposed || watcher.breached())
|
|
59
|
+
return;
|
|
60
|
+
const teammates = await args.manager.listByTask(args.team);
|
|
61
|
+
for (const agent of teammates) {
|
|
62
|
+
// Only tap running local teammates: PENDING has no output yet, cloud
|
|
63
|
+
// teammates emit through a different channel (their own SSE stream),
|
|
64
|
+
// and terminal states produce no new bytes.
|
|
65
|
+
if (agent.status !== 'running')
|
|
66
|
+
continue;
|
|
67
|
+
if (agent.cloudProvider)
|
|
68
|
+
continue;
|
|
69
|
+
const stdoutPath = await agent.getStdoutPath();
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = fs.statSync(stdoutPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue; // File not yet created — nothing to read.
|
|
76
|
+
}
|
|
77
|
+
const cursor = cursors.get(agent.agentId) ?? { offset: 0, pending: '' };
|
|
78
|
+
if (stat.size <= cursor.offset) {
|
|
79
|
+
cursors.set(agent.agentId, cursor);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const fd = fs.openSync(stdoutPath, 'r');
|
|
83
|
+
try {
|
|
84
|
+
const toRead = stat.size - cursor.offset;
|
|
85
|
+
const buffer = Buffer.alloc(toRead);
|
|
86
|
+
const bytesRead = fs.readSync(fd, buffer, 0, toRead, cursor.offset);
|
|
87
|
+
const chunk = buffer.toString('utf-8', 0, bytesRead);
|
|
88
|
+
cursor.offset += bytesRead;
|
|
89
|
+
const { events, rest } = extractUsageEvents(chunk, cursor.pending, undefined, agent.agentType);
|
|
90
|
+
cursor.pending = rest;
|
|
91
|
+
for (const ev of events) {
|
|
92
|
+
watcher.feedUsage(ev);
|
|
93
|
+
if (watcher.breached())
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
cursors.set(agent.agentId, cursor);
|
|
101
|
+
if (watcher.breached())
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
breached: () => watcher.breached(),
|
|
106
|
+
breach: () => firstBreach,
|
|
107
|
+
dispose() {
|
|
108
|
+
if (disposed)
|
|
109
|
+
return;
|
|
110
|
+
disposed = true;
|
|
111
|
+
watcher.dispose();
|
|
112
|
+
cursors.clear();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/lib/cloud/codex.js
CHANGED
|
@@ -104,6 +104,10 @@ export class CodexCloudProvider {
|
|
|
104
104
|
cancel: false,
|
|
105
105
|
message: false,
|
|
106
106
|
multiRepo: false,
|
|
107
|
+
// `codex cloud exec --env <id> <prompt>` is the only dispatch surface the
|
|
108
|
+
// codex CLI exposes — it has no flag to attach images or ride-along skills,
|
|
109
|
+
// and the env bundles its own context at creation time. Both stay false
|
|
110
|
+
// until the upstream CLI grows an attachment surface.
|
|
107
111
|
skills: false,
|
|
108
112
|
images: false,
|
|
109
113
|
};
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Auth: reads the session token from ~/.rush/user.yaml (written by `rush login`).
|
|
5
5
|
* Requires the Rush GitHub App installed on the target repo.
|
|
6
6
|
*/
|
|
7
|
-
import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities } from './types.js';
|
|
7
|
+
import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
|
|
8
8
|
export declare const RUSH_CONSENT_PATH: string;
|
|
9
9
|
export declare function hasRushUploadConsent(accountFingerprint: string, opts?: DispatchOptions, consentPath?: string): boolean;
|
|
10
10
|
/** One version's entry in the account manifest sent on every dispatch. */
|
|
@@ -63,6 +63,17 @@ export declare function buildDispatchBody(input: {
|
|
|
63
63
|
}>;
|
|
64
64
|
accountManifest?: AccountManifest | null;
|
|
65
65
|
accountTokens?: AccountTokenEntry[] | null;
|
|
66
|
+
/**
|
|
67
|
+
* Skill ride-alongs so the cloud pod isn't context-blind. Forwarded verbatim
|
|
68
|
+
* as `skills` so the Factory Floor can mount them by id/version before the
|
|
69
|
+
* agent runs. Omitted when empty.
|
|
70
|
+
*/
|
|
71
|
+
skills?: SkillRef[] | null;
|
|
72
|
+
/**
|
|
73
|
+
* Base64 image attachments for vision dispatch. Sliced to
|
|
74
|
+
* MAX_IMAGES_PER_DISPATCH — extras are dropped, never sent. Omitted when empty.
|
|
75
|
+
*/
|
|
76
|
+
images?: ImageAttachment[] | null;
|
|
66
77
|
}): Record<string, unknown>;
|
|
67
78
|
/** A single account registered in Rush Cloud's multi-account rotation pool. */
|
|
68
79
|
export interface RemoteAccount {
|