@phnx-labs/agents-cli 1.20.74 → 1.20.77
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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -8,41 +8,41 @@
|
|
|
8
8
|
*
|
|
9
9
|
* agents watchdog one tick, dry — prints what it WOULD nudge/skip and why
|
|
10
10
|
* agents watchdog --nudge one tick, actually injects (explicit opt-in)
|
|
11
|
-
* agents watchdog --watch
|
|
11
|
+
* agents watchdog --watch manual poll loop (dry unless --nudge)
|
|
12
12
|
* agents watchdog --json machine-readable tick output (for the menu-bar)
|
|
13
|
-
* agents watchdog enable|disable
|
|
13
|
+
* agents watchdog enable|disable turn the always-on watchdog routine on/off
|
|
14
14
|
* agents watchdog policy <id> <p> per-session policy: off | keep | handsoff
|
|
15
|
+
*
|
|
16
|
+
* The always-on watchdog is a daemon-fired ROUTINE, not a private sentinel + a
|
|
17
|
+
* hand-rolled loop: `enable` creates/enables a `watchdog` command routine
|
|
18
|
+
* (`agents watchdog --nudge` every couple of minutes) and reloads the daemon;
|
|
19
|
+
* `disable` pauses it. See ../lib/watchdog/routine.ts.
|
|
15
20
|
*/
|
|
16
21
|
import chalk from 'chalk';
|
|
17
|
-
import * as fs from 'fs';
|
|
18
22
|
import * as path from 'path';
|
|
19
23
|
import { setHelpSections } from '../lib/help.js';
|
|
20
24
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
21
25
|
import { getRuntimeStateDir } from '../lib/state.js';
|
|
26
|
+
import { setJobEnabled } from '../lib/routines.js';
|
|
27
|
+
import { ensureWatchdogRoutine, isWatchdogRoutineEnabled, watchdogRoutineExists, WATCHDOG_ROUTINE_NAME, WATCHDOG_ROUTINE_SCHEDULE, } from '../lib/watchdog/routine.js';
|
|
22
28
|
import { runWatchdogTick, writePolicySentinel, DEFAULT_THRESHOLDS, } from '../lib/watchdog/runner.js';
|
|
23
29
|
/** Default state dir the runner and these subcommands share. */
|
|
24
30
|
function stateDir() {
|
|
25
31
|
return path.join(getRuntimeStateDir(), 'watchdog');
|
|
26
32
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
-
fs.writeFileSync(p, 'enabled\n');
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
try {
|
|
42
|
-
fs.rmSync(p);
|
|
43
|
-
}
|
|
44
|
-
catch { /* already off */ }
|
|
33
|
+
/**
|
|
34
|
+
* (Re)load the daemon so a just-changed routine takes effect without a restart.
|
|
35
|
+
* Best-effort: enabling starts the daemon if it is not running, then SIGHUPs it.
|
|
36
|
+
* Dynamic import keeps daemon.ts's heavy deps off the watchdog command's load path.
|
|
37
|
+
*/
|
|
38
|
+
async function reloadDaemonForRoutine(startIfStopped) {
|
|
39
|
+
const { isDaemonRunning, ensureDaemonStarted, signalDaemonReload } = await import('../lib/daemon.js');
|
|
40
|
+
if (isDaemonRunning()) {
|
|
41
|
+
signalDaemonReload();
|
|
42
|
+
return;
|
|
45
43
|
}
|
|
44
|
+
if (startIfStopped)
|
|
45
|
+
ensureDaemonStarted();
|
|
46
46
|
}
|
|
47
47
|
/** Parse a duration flag ("60s", "5m", "1h") to ms, or fall back to `fallbackMs`. */
|
|
48
48
|
function durationMsOr(raw, fallbackMs) {
|
|
@@ -97,7 +97,7 @@ export function registerWatchdogCommand(program) {
|
|
|
97
97
|
.command('watchdog')
|
|
98
98
|
.description('Auto-nudge stalled agent terminals: detect stalls, resolve the exact split, inject "Continue." — no menu-bar needed.')
|
|
99
99
|
.option('--nudge', 'Actually inject (default is a dry run that only reports what it would do)')
|
|
100
|
-
.option('--watch', '
|
|
100
|
+
.option('--watch', 'Manual poll loop: run a tick every --interval (dry unless --nudge; the always-on path is `watchdog enable`)')
|
|
101
101
|
.option('--interval <dur>', 'Poll interval in --watch mode (e.g. 30s, 1m)', '30s')
|
|
102
102
|
.option('--stall <dur>', 'Idle time before a session counts as stalled', humanMs(DEFAULT_THRESHOLDS.stallMs))
|
|
103
103
|
.option('--cooldown <dur>', 'Minimum time between nudges to the same session', humanMs(DEFAULT_THRESHOLDS.cooldownMs))
|
|
@@ -113,12 +113,11 @@ export function registerWatchdogCommand(program) {
|
|
|
113
113
|
cooldownMs: durationMsOr(opts.cooldown, DEFAULT_THRESHOLDS.cooldownMs),
|
|
114
114
|
dormantMs: durationMsOr(opts.dormant, DEFAULT_THRESHOLDS.dormantMs),
|
|
115
115
|
};
|
|
116
|
-
// Injection gate: --nudge is the explicit
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
const computeWillInject = () => opts.nudge === true || (opts.watch === true && isGloballyEnabled());
|
|
116
|
+
// Injection gate: --nudge is the explicit opt-in to actually inject. Bare
|
|
117
|
+
// `agents watchdog` (and `--watch` without `--nudge`) is dry. The always-on
|
|
118
|
+
// path is the daemon routine, which runs `agents watchdog --nudge` — so the
|
|
119
|
+
// routine's enabled state IS the on/off switch, not a flag read here.
|
|
120
|
+
const computeWillInject = () => opts.nudge === true;
|
|
122
121
|
const tickOnce = async (willInject) => runWatchdogTick({
|
|
123
122
|
nudge: willInject,
|
|
124
123
|
nudgeText: opts.text,
|
|
@@ -137,11 +136,11 @@ export function registerWatchdogCommand(program) {
|
|
|
137
136
|
printTick(result, willInject);
|
|
138
137
|
return;
|
|
139
138
|
}
|
|
140
|
-
//
|
|
139
|
+
// Manual poll loop (for ad-hoc use; the always-on path is `enable`'s routine).
|
|
141
140
|
const intervalMs = durationMsOr(opts.interval, 30_000);
|
|
142
141
|
if (!computeWillInject() && !opts.json) {
|
|
143
|
-
console.log(chalk.yellow(`watchdog --watch is DETECT-ONLY
|
|
144
|
-
`
|
|
142
|
+
console.log(chalk.yellow(`watchdog --watch is DETECT-ONLY. Pass --nudge to inject, ` +
|
|
143
|
+
`or run 'agents watchdog enable' for the always-on daemon routine.`));
|
|
145
144
|
}
|
|
146
145
|
// eslint-disable-next-line no-constant-condition
|
|
147
146
|
while (true) {
|
|
@@ -163,50 +162,67 @@ export function registerWatchdogCommand(program) {
|
|
|
163
162
|
# One tick, actually inject "Continue." into stalled+addressable splits
|
|
164
163
|
agents watchdog --nudge
|
|
165
164
|
|
|
166
|
-
#
|
|
167
|
-
agents watchdog --watch --interval 30s --stall 60s --cooldown 5m
|
|
165
|
+
# Manual watch loop every 30s, tighter stall threshold (ad-hoc; dry unless --nudge)
|
|
166
|
+
agents watchdog --watch --nudge --interval 30s --stall 60s --cooldown 5m
|
|
168
167
|
|
|
169
168
|
# Machine-readable for the menu-bar
|
|
170
169
|
agents watchdog --json
|
|
171
170
|
|
|
172
|
-
# Turn on the
|
|
171
|
+
# Turn on the ALWAYS-ON watchdog (a daemon-fired routine)
|
|
173
172
|
agents watchdog enable
|
|
174
173
|
|
|
175
174
|
# Leave one session detected-but-untouched
|
|
176
175
|
agents watchdog policy <sessionId> handsoff
|
|
177
176
|
`,
|
|
178
177
|
notes: `
|
|
179
|
-
Decision path
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
Decision path: a cheap deterministic pre-filter resolves the obvious cases
|
|
179
|
+
(clearly complete -> skip; a clear promise-without-toolcall -> nudge) and
|
|
180
|
+
ESCALATES the judgment-heavy cases -- a session parked on a question, or an
|
|
181
|
+
ambiguous stall -- to a smart brain. The brain drives the agent to finish
|
|
182
|
+
end-to-end when it asked a needless / already-authorized question or paused
|
|
183
|
+
with work left, and leaves it for the human on genuine cases (credentials,
|
|
184
|
+
an irreversible or outward-facing action, a real ambiguous decision, or a
|
|
185
|
+
completed task). The brain is a customizable 'watchdog' workflow (drop a
|
|
186
|
+
WORKFLOW.md in project/user workflows/ to override the prompt + model);
|
|
187
|
+
absent one, the built-in prompt runs via 'agents run --mode plan'. Pass
|
|
188
|
+
--smart to force every stalled candidate through the brain.
|
|
183
189
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
Delivery (answer-router): a running agent is steered via its mailbox; a
|
|
191
|
+
parked-on-question agent is answered into its EXACT split -- tmux / iTerm /
|
|
192
|
+
an IDE integrated terminal (VS Codium / Cursor / VS Code) -- or re-entered
|
|
193
|
+
via resume when headless. A parked agent with no addressable rail (e.g.
|
|
194
|
+
Ghostty with no tmux) is flagged for the menu-bar and SKIPPED -- never a
|
|
195
|
+
guessed or frontmost target.
|
|
188
196
|
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
Always-on: 'agents watchdog enable' creates + enables a 'watchdog' command
|
|
198
|
+
routine ('${WATCHDOG_ROUTINE_SCHEDULE}' -> agents watchdog --nudge) and reloads the
|
|
199
|
+
daemon; 'disable' pauses it. Inspect it with 'agents routines list'. Defaults
|
|
200
|
+
OFF. Per-session policy: off (ignore), keep (default), handsoff (detect + flag).
|
|
191
201
|
|
|
192
202
|
State (tray-readable): ${path.join('~/.agents/.cache/state/watchdog', '{nudges,flags,last-tick}.json')}
|
|
193
203
|
`,
|
|
194
204
|
});
|
|
195
|
-
// ---
|
|
205
|
+
// --- always-on enable/disable/status (backed by the daemon routine) --------
|
|
196
206
|
cmd.command('enable')
|
|
197
|
-
.description('Turn ON
|
|
198
|
-
.action(() => {
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
.description('Turn ON the always-on watchdog: create/enable the `watchdog` routine and (re)load the daemon.')
|
|
208
|
+
.action(async () => {
|
|
209
|
+
ensureWatchdogRoutine(true);
|
|
210
|
+
await reloadDaemonForRoutine(true);
|
|
211
|
+
console.log(chalk.green(`watchdog: ENABLED — routine '${WATCHDOG_ROUTINE_NAME}' fires ${WATCHDOG_ROUTINE_SCHEDULE} (agents watchdog --nudge)`));
|
|
201
212
|
});
|
|
202
213
|
cmd.command('disable')
|
|
203
|
-
.description('Turn OFF
|
|
204
|
-
.action(() => {
|
|
205
|
-
|
|
206
|
-
|
|
214
|
+
.description('Turn OFF the always-on watchdog (pause the `watchdog` routine).')
|
|
215
|
+
.action(async () => {
|
|
216
|
+
if (!watchdogRoutineExists()) {
|
|
217
|
+
console.log(chalk.dim('watchdog: already off (no routine)'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
setJobEnabled(WATCHDOG_ROUTINE_NAME, false);
|
|
221
|
+
await reloadDaemonForRoutine(false);
|
|
222
|
+
console.log(chalk.yellow('watchdog: DISABLED (routine paused)'));
|
|
207
223
|
});
|
|
208
224
|
cmd.command('status')
|
|
209
|
-
.description('Show whether
|
|
225
|
+
.description('Show whether the always-on watchdog routine is enabled and where state is written.')
|
|
210
226
|
.option('--json', 'Emit status as JSON (for the menu-bar / scripts)')
|
|
211
227
|
.action((_opts, command) => {
|
|
212
228
|
// The parent `watchdog` command also declares --json and greedily parses it
|
|
@@ -214,12 +230,12 @@ export function registerWatchdogCommand(program) {
|
|
|
214
230
|
// parent, not this subcommand. optsWithGlobals() merges both levels, so we
|
|
215
231
|
// read it correctly regardless of which command commander bound it to.
|
|
216
232
|
const json = command.optsWithGlobals().json === true;
|
|
217
|
-
const on =
|
|
233
|
+
const on = isWatchdogRoutineEnabled();
|
|
218
234
|
if (json) {
|
|
219
|
-
console.log(JSON.stringify({ enabled: on, stateDir: stateDir() }));
|
|
235
|
+
console.log(JSON.stringify({ enabled: on, routine: WATCHDOG_ROUTINE_NAME, stateDir: stateDir() }));
|
|
220
236
|
return;
|
|
221
237
|
}
|
|
222
|
-
console.log(`
|
|
238
|
+
console.log(`always-on watchdog: ${on ? chalk.green('ON') : chalk.dim('off')} (routine '${WATCHDOG_ROUTINE_NAME}')`);
|
|
223
239
|
console.log(`state dir: ${chalk.dim(stateDir())}`);
|
|
224
240
|
});
|
|
225
241
|
// --- per-session policy ----------------------------------------------------
|
package/dist/index.js
CHANGED
|
@@ -26,11 +26,50 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
26
26
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
|
27
27
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
28
28
|
const VERSION = packageJson.version;
|
|
29
|
+
import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './lib/secrets/sync-commands.js';
|
|
29
30
|
if (process.argv[2] === '__vault-age-helper') {
|
|
30
31
|
const { runVaultAgeHelperCli } = await import('./lib/secrets/vault-age-helper.js');
|
|
31
32
|
await runVaultAgeHelperCli();
|
|
32
33
|
process.exit(process.exitCode ?? 0);
|
|
33
34
|
}
|
|
35
|
+
// Synchronous secrets-broker clients (src/lib/secrets/agent.ts). These are the
|
|
36
|
+
// hot read path: `readAndResolveBundleEnv` is synchronous all the way down, so
|
|
37
|
+
// it can't await a socket round-trip — it spawns one of these and reads the
|
|
38
|
+
// exit code (0 = hit/alive, 3 = miss/down).
|
|
39
|
+
//
|
|
40
|
+
// Intercepted HERE, before commander and before the startup STATEMENTS, for the
|
|
41
|
+
// same reason as __daemon-run and __vault-age-helper above. Everything below
|
|
42
|
+
// this point runs `checkForUpdates()` and `spawnDetachedSync()` on every
|
|
43
|
+
// non-help invocation — so registering these as ordinary hidden subcommands
|
|
44
|
+
// would fire an update check and fork a detached background sync on *every
|
|
45
|
+
// cache hit*, which is both a fork storm on the hot path and a source of stdout
|
|
46
|
+
// writes that could corrupt the JSON payload. Keep them above the line;
|
|
47
|
+
// agent.test.ts asserts this ordering so the block can't drift downward.
|
|
48
|
+
//
|
|
49
|
+
// The tokens are imported from the leaf module sync-commands.ts — the SAME
|
|
50
|
+
// bindings the clients spawn with, so this dispatch and those spawns cannot
|
|
51
|
+
// drift apart. It is a leaf precisely so binding them here is free: importing
|
|
52
|
+
// agent.js would pull the whole secrets graph into every invocation. Matching
|
|
53
|
+
// string literals were the earlier design; a text-scanning test could not
|
|
54
|
+
// reliably catch their divergence, and the divergence is silent — the CLI says
|
|
55
|
+
// "unknown command", the client reads that as "broker down", and every read
|
|
56
|
+
// falls back to a Touch ID prompt.
|
|
57
|
+
//
|
|
58
|
+
// "Before the startup statements", not "before all code": ESM hoists the
|
|
59
|
+
// imports below, so those module bodies evaluate first. They are side-effect
|
|
60
|
+
// free today (no top-level stdout write or spawn) — which is what makes this
|
|
61
|
+
// safe, and worth preserving.
|
|
62
|
+
if (process.argv[2] === SYNC_GET_CMD ||
|
|
63
|
+
process.argv[2] === SYNC_PING_CMD ||
|
|
64
|
+
process.argv[2] === SYNC_LOCK_CMD) {
|
|
65
|
+
const { runAgentGetSync, runAgentPingSync, runAgentLockSync } = await import('./lib/secrets/agent.js');
|
|
66
|
+
const name = process.argv[3] ?? '';
|
|
67
|
+
const harness = process.argv[4] ?? 'cli';
|
|
68
|
+
const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name, harness)
|
|
69
|
+
: process.argv[2] === SYNC_PING_CMD ? await runAgentPingSync()
|
|
70
|
+
: await runAgentLockSync(name);
|
|
71
|
+
process.exit(code);
|
|
72
|
+
}
|
|
34
73
|
import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, installPackageIntoPrefix, installPackageWithBun, verifyInstalledVersion, refreshAliasShims, downloadVerifiedTarball, } from './lib/self-update.js';
|
|
35
74
|
// Detect dev/working-tree builds and default the noisy startup steps off.
|
|
36
75
|
// Three cases trip this:
|
|
@@ -55,9 +94,10 @@ if (IS_DEV_BUILD) {
|
|
|
55
94
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
56
95
|
// first byte of output), the registry maps a command name to a thunk that
|
|
57
96
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
58
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadResources, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
97
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadResources, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
59
98
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
60
99
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
100
|
+
import { getCliLaunch } from './lib/cli-entry.js';
|
|
61
101
|
import { emit, redactArgs } from './lib/events.js';
|
|
62
102
|
// Transparent shim delegate: the generated Windows `.cmd` shims invoke
|
|
63
103
|
// `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
|
|
@@ -294,7 +334,7 @@ async function showWhatsNew(fromVersion, toVersion) {
|
|
|
294
334
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
295
335
|
import { getUpdateCheckPath, getMigratedSentinelPath, getUserAgentsDir, getRuntimeStateDir } from './lib/state.js';
|
|
296
336
|
import { resolveBrandName, disabledCommandsForActiveBrand } from './lib/brand.js';
|
|
297
|
-
import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, findAgentsCliInstalls, } from './lib/self-update.js';
|
|
337
|
+
import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, findAgentsCliInstalls, resolveRunningPackageRoot, } from './lib/self-update.js';
|
|
298
338
|
const UPDATE_CHECK_FILE = getUpdateCheckPath();
|
|
299
339
|
/**
|
|
300
340
|
* Warn once when PATH resolves `agents` to a different agents-cli install
|
|
@@ -306,7 +346,16 @@ const UPDATE_CHECK_FILE = getUpdateCheckPath();
|
|
|
306
346
|
*/
|
|
307
347
|
function maybeWarnMultiInstall() {
|
|
308
348
|
const sentinel = path.join(getRuntimeStateDir(), 'multi-install-warned');
|
|
309
|
-
|
|
349
|
+
let runningRoot;
|
|
350
|
+
try {
|
|
351
|
+
runningRoot = resolveRunningPackageRoot(__dirname);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Without a real root for the running copy there is nothing to compare
|
|
355
|
+
// against, and a guess here is exactly what produced the phantom
|
|
356
|
+
// "/$bunfs" install. This warning is advisory — stay silent instead.
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
310
359
|
const byRoot = new Map();
|
|
311
360
|
byRoot.set(runningRoot, { version: VERSION, note: 'running' });
|
|
312
361
|
for (const install of findAgentsCliInstalls(process.env.PATH || '')) {
|
|
@@ -370,7 +419,7 @@ function printResolvedPackage(metadata) {
|
|
|
370
419
|
console.log(chalk.gray(`Integrity: ${metadata.integrity}`));
|
|
371
420
|
}
|
|
372
421
|
async function installResolvedPackage(metadata) {
|
|
373
|
-
const packageRoot =
|
|
422
|
+
const packageRoot = resolveRunningPackageRoot(__dirname);
|
|
374
423
|
// Download the published tarball and prove its bytes match the registry
|
|
375
424
|
// integrity BEFORE installing anything. A `name@version` spec would let the
|
|
376
425
|
// package manager fetch and install whatever the registry serves with no
|
|
@@ -466,9 +515,11 @@ async function promptUpgrade(latestVersion) {
|
|
|
466
515
|
console.log();
|
|
467
516
|
// Re-exec the verified install's entrypoint and exit. PATH lookup of
|
|
468
517
|
// `agents` could resolve a different copy (dev build, another prefix)
|
|
469
|
-
// than the one that was just upgraded.
|
|
470
|
-
|
|
471
|
-
|
|
518
|
+
// than the one that was just upgraded. getCliLaunch resolves the JS-vs-
|
|
519
|
+
// standalone shape — never hand-roll `[process.execPath, entrypoint]`,
|
|
520
|
+
// which hands the bun virtual entry to a compiled binary as a bogus arg.
|
|
521
|
+
const { command, args } = getCliLaunch(process.argv.slice(2));
|
|
522
|
+
const result = spawnSync(command, args, {
|
|
472
523
|
stdio: 'inherit',
|
|
473
524
|
shell: false,
|
|
474
525
|
});
|
|
@@ -747,6 +798,7 @@ async function registerAllEagerCommands() {
|
|
|
747
798
|
await reg(loadStatus);
|
|
748
799
|
registerExecAliasCommand(program);
|
|
749
800
|
await reg(loadProfiles);
|
|
801
|
+
await reg(loadHarness);
|
|
750
802
|
await reg(loadSecrets);
|
|
751
803
|
await reg(loadLogin);
|
|
752
804
|
await reg(loadWallet);
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { EventRecord } from './events.js';
|
|
2
2
|
/** Recognizable milestone events, ordered first in any activity lane. */
|
|
3
|
-
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created'
|
|
3
|
+
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
|
|
4
|
+
/** Deliberate agent-authored progress post (`agents feed post`). */
|
|
5
|
+
| 'status.posted';
|
|
4
6
|
/** Routine activity events, collapsed to counts by readers. */
|
|
5
7
|
export type ActivityKind = 'file.edited';
|
|
6
8
|
export type ActivityEventKind = MilestoneEvent | ActivityKind;
|
|
@@ -29,12 +31,20 @@ export interface ActivityEvent {
|
|
|
29
31
|
cwd?: string;
|
|
30
32
|
/** Agent that produced the event (claude, codex, ...). */
|
|
31
33
|
agent?: string;
|
|
32
|
-
/** Tool that triggered the event (Bash, Task, ExitPlanMode, ...). */
|
|
34
|
+
/** Tool that triggered the event (Bash, Task, ExitPlanMode, feed.post, ...). */
|
|
33
35
|
tool?: string;
|
|
34
|
-
/** One-line human summary (plan title, PR command, sub-agent role). */
|
|
36
|
+
/** One-line human summary (plan title, PR command, sub-agent role, status text). */
|
|
35
37
|
detail?: string;
|
|
36
38
|
/** Extracted URL when the event has one (e.g. the opened PR). */
|
|
37
39
|
url?: string;
|
|
40
|
+
/** Auto-stamped process identity for deliberate posts (from pid registry / env). */
|
|
41
|
+
pid?: number;
|
|
42
|
+
/** Spawn-time join key (`AGENT_LAUNCH_ID`) when known. */
|
|
43
|
+
launchId?: string;
|
|
44
|
+
/** Factory terminal id when the launch inherited one. */
|
|
45
|
+
terminalId?: string;
|
|
46
|
+
/** `$TMUX_PANE` at launch when recorded. */
|
|
47
|
+
tmuxPane?: string;
|
|
38
48
|
}
|
|
39
49
|
/**
|
|
40
50
|
* Append one event to a session's activity log. Primarily the Python hook
|
|
@@ -107,10 +117,11 @@ export declare function formatActivityLine(ev: ActivityEvent, opts?: {
|
|
|
107
117
|
* line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
|
|
108
118
|
*
|
|
109
119
|
* Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
|
|
110
|
-
* Edit|MultiEdit) so
|
|
111
|
-
*
|
|
120
|
+
* Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
|
|
121
|
+
* read-only tools never pay the hook cost. Fail-open: any error is swallowed so
|
|
122
|
+
* a logging hiccup never blocks a tool call.
|
|
112
123
|
*/
|
|
113
|
-
export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
124
|
+
export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One\nappend-only file per session; read-only tools never trigger it because the\nmanifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"task.completed\", \"checklist.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n# Checklist tools across harnesses. The value is the key that holds the item list.\nCHECKLIST_TOOLS = {\n \"TodoWrite\": \"todos\",\n \"todo_write\": \"todos\",\n \"TaskUpdate\": \"tasks\",\n \"update_plan\": \"plan\",\n}\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef _checklist_items(tool_input, kind):\n \"\"\"Extract normalized checklist items from tool input.\"\"\"\n if not isinstance(tool_input, dict):\n return []\n if kind == \"todos\":\n arr = tool_input.get(\"todos\") or []\n elif kind == \"tasks\":\n arr = tool_input.get(\"tasks\") or []\n if not arr and \"taskId\" in tool_input:\n arr = [tool_input]\n elif kind == \"plan\":\n arr = tool_input.get(\"plan\") or []\n else:\n return []\n if not isinstance(arr, list):\n return []\n\n items = []\n for t in arr:\n if not isinstance(t, dict):\n continue\n subject = (\n t.get(\"content\") or\n t.get(\"text\") or\n t.get(\"step\") or\n t.get(\"title\") or\n t.get(\"description\") or\n t.get(\"activeForm\") or\n \"\"\n )\n if not isinstance(subject, str):\n subject = str(subject)\n subject = subject.strip()\n item_id = t.get(\"id\") or t.get(\"taskId\") or subject\n if not item_id:\n continue\n status = str(t.get(\"status\", \"\") or \"\").lower()\n items.append({\"id\": item_id, \"subject\": subject, \"status\": status})\n return items\n\n\ndef _read_transcript_checklists(transcript_path, current_tool, current_items):\n \"\"\"Tail-read the session transcript and return the previous checklist state.\n\n The most recent checklist entry is assumed to be the current tool call if\n it carries the same ids; skip that one and return the next older checklist\n state (or None if there isn't one).\n \"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return None\n try:\n size = os.path.getsize(transcript_path)\n start = max(0, size - 512 * 1024)\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n f.seek(start)\n if start > 0:\n f.readline() # drop a leading partial line\n lines = f.readlines()\n except Exception:\n return None\n\n current_ids = {str(item.get(\"id\")) for item in current_items if item.get(\"id\")}\n skipped_current = False\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n if isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n elif record.get(\"tool_name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or tu.get(\"tool_name\") or \"\"\n if name not in CHECKLIST_TOOLS:\n continue\n kind = CHECKLIST_TOOLS[name]\n args = tu.get(\"input\") or tu.get(\"tool_input\") or tu.get(\"arguments\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n items = _checklist_items(args, kind)\n if not items:\n continue\n ids = {str(item.get(\"id\")) for item in items if item.get(\"id\")}\n # Skip the most recent matching checklist entry once; that is the\n # current call already reflected in the transcript.\n if not skipped_current and name == current_tool and ids == current_ids:\n skipped_current = True\n continue\n return items\n return None\n\n\ndef _has_previous_task_create(transcript_path, current_tool_use_id=\"\"):\n \"\"\"Return True if the transcript contains a TaskCreate before the current one.\"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return False\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return False\n\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n if (tu.get(\"name\") or tu.get(\"tool_name\")) == \"TaskCreate\":\n if tu.get(\"id\") != current_tool_use_id:\n return True\n return False\n\n\ndef _claude_task_state(transcript_path, exclude_task_id=None):\n \"\"\"Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.\n\n TaskCreate provides the subject (and id via toolUseResult); TaskUpdate\n provides the status. If exclude_task_id is given, the last TaskUpdate for\n that id is skipped (it is the current call already reflected in the\n transcript).\n \"\"\"\n state = {}\n if not transcript_path or not os.path.exists(transcript_path):\n return state\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return state\n\n updates = [] # (task_id, status, line_index)\n for idx, line in enumerate(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in tool_uses:\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or \"\"\n args = tu.get(\"input\") or tu.get(\"tool_input\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n if name == \"TaskCreate\":\n subject = (\n args.get(\"subject\") or\n args.get(\"description\") or\n args.get(\"title\") or\n \"\"\n )\n if subject:\n # Link subject to the tool_use id so the result can map it.\n tool_id = tu.get(\"id\")\n if tool_id:\n state.setdefault(\"__pending_subject\", {})[tool_id] = subject\n\n tool_result = record.get(\"toolUseResult\") or {}\n if not isinstance(tool_result, dict):\n continue\n task = tool_result.get(\"task\")\n if isinstance(task, dict):\n task_id = str(task.get(\"id\") or task.get(\"taskId\") or \"\")\n if task_id:\n state.setdefault(task_id, {})\n if task.get(\"subject\"):\n state[task_id][\"subject\"] = task[\"subject\"]\n if task.get(\"status\"):\n state[task_id][\"status\"] = str(task.get(\"status\")).lower()\n # Link any pending subject from the matching tool_use id.\n tool_use_id = record.get(\"tool_use_id\") or \"\"\n pending = state.get(\"__pending_subject\", {})\n if tool_use_id and tool_use_id in pending:\n state[task_id][\"subject\"] = pending[tool_use_id]\n task_id = str(tool_result.get(\"taskId\") or \"\")\n status_change = tool_result.get(\"statusChange\") or {}\n status = status_change.get(\"to\") or tool_result.get(\"status\")\n if task_id and status:\n state.setdefault(task_id, {})\n state[task_id][\"status\"] = str(status).lower()\n updates.append((task_id, str(status).lower(), idx))\n\n if exclude_task_id and updates:\n for i in range(len(updates) - 1, -1, -1):\n if updates[i][0] == exclude_task_id:\n del updates[i]\n break\n # Rebuild statuses from remaining updates.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if \"subject\" not in state[task_id]:\n del state[task_id]\n else:\n state[task_id].pop(\"status\", None)\n for task_id, status, _ in updates:\n if task_id in state:\n state[task_id][\"status\"] = status\n\n # Drop tasks removed from the checklist (deleted/cancelled): they are gone\n # from the user-visible list, so they must not count toward the N/M total.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if state[task_id].get(\"status\") in (\"deleted\", \"cancelled\", \"canceled\", \"removed\"):\n del state[task_id]\n\n # Drop internal bookkeeping.\n state.pop(\"__pending_subject\", None)\n return state\n\n\ndef _checklist_events(payload, hook_event):\n \"\"\"Return a list of (event, detail) tuples for checklist tool calls.\"\"\"\n if hook_event != \"PostToolUse\":\n return []\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n\n if tool_name == \"TaskCreate\":\n subject = (\n tool_input.get(\"subject\") or\n tool_input.get(\"description\") or\n tool_input.get(\"title\") or\n \"task\"\n )\n # Only announce the checklist on the very first task creation.\n current_tool_use_id = payload.get(\"tool_use_id\", \"\")\n transcript_path = payload.get(\"transcript_path\")\n if _has_previous_task_create(transcript_path, current_tool_use_id):\n return []\n return [(\"checklist.created\", subject)]\n\n if tool_name not in CHECKLIST_TOOLS:\n return []\n\n kind = CHECKLIST_TOOLS[tool_name]\n items = _checklist_items(tool_input, kind)\n if not items:\n return []\n\n # Full-list tools (TodoWrite, update_plan) send the whole checklist every\n # time. Per-task update tools (TaskUpdate) send only the changed item, so\n # totals and subject must be resolved from the previous full-list state.\n list_key = {\"todos\": \"todos\", \"tasks\": \"tasks\", \"plan\": \"plan\"}.get(kind)\n is_full_list = list_key is not None and list_key in tool_input\n\n previous = _read_transcript_checklists(\n payload.get(\"transcript_path\"), tool_name, items\n )\n\n if not is_full_list and previous:\n total = len(previous)\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n done_count = len(previous_done)\n events = []\n for item in items:\n if item.get(\"status\") != \"completed\":\n continue\n item_id = str(item.get(\"id\"))\n if item_id in previous_done:\n continue\n subject = item.get(\"subject\")\n if not subject:\n for p in previous:\n if str(p.get(\"id\")) == item_id and p.get(\"subject\"):\n subject = p[\"subject\"]\n break\n if not subject:\n subject = \"task\"\n done_count += 1\n events.append((\"task.completed\", f\"{subject} {done_count}/{total} done\"))\n return events\n\n # For TaskUpdate without a previous full-list state, fold the Claude\n # TaskCreate/TaskUpdate history to resolve subject and N/M.\n if tool_name == \"TaskUpdate\" and previous is None:\n task_id = str(items[0].get(\"id\")) if items else \"\"\n task_state = _claude_task_state(\n payload.get(\"transcript_path\"), exclude_task_id=task_id\n )\n if task_state:\n total = len(task_state)\n previous_done = {\n tid for tid, info in task_state.items()\n if info.get(\"status\") == \"completed\"\n }\n done_count = len(previous_done)\n if task_id in task_state and items[0].get(\"status\") == \"completed\" and task_id not in previous_done:\n subject = task_state[task_id].get(\"subject\") or \"task\"\n return [(\"task.completed\", f\"{subject} {done_count + 1}/{total} done\")]\n return []\n\n total = len(items)\n completed = [i for i in items if i.get(\"status\") == \"completed\"]\n\n if previous is None:\n # First checklist call in this session.\n events = []\n events.append((\"checklist.created\", f\"{total} task{'s' if total != 1 else ''}\"))\n for item in completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {len(completed)}/{total} done\",\n ))\n return events\n\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n newly_completed = [\n i for i in completed\n if str(i.get(\"id\")) not in previous_done\n ]\n if not newly_completed:\n return []\n\n done_count = len(completed)\n events = []\n for item in newly_completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {done_count}/{total} done\",\n ))\n return events\n\n\ndef _make_record(event, detail, tool_name):\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n record[\"tool\"] = tool_name\n return record\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n # Checklist completions first -- cheap guard above already filtered tool name.\n checklist = _checklist_events(payload, hook_event)\n if checklist:\n return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return [], tool_name\n\n record = _make_record(event, detail, tool_name)\n if url:\n record[\"url\"] = url\n return [record], tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n records, tool_name = build_event(payload, hook_event)\n if not records:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n for record in records:\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n try:\n over_limit = os.path.getsize(target) > MAX_LOG_BYTES\n except OSError:\n over_limit = False\n with open(target, \"a\") as f:\n for record in records:\n if over_limit and record.get(\"tier\") != \"milestone\":\n continue\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
114
125
|
/** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
|
|
115
126
|
export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
|
|
116
127
|
/**
|