agent-afk 5.275.5 → 5.276.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-afk",
3
- "version": "5.275.5",
3
+ "version": "5.276.0",
4
4
  "description": "Open-source coding-agent harness you can actually change — own the loop (prompts, gates, routing, skills, terminal states), use any model, run long tasks while you're away.",
5
5
  "main": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -1,24 +0,0 @@
1
- import type { OutputEvent } from '../types/session-types.js';
2
- export declare const MAX_RECORD_BYTES: number;
3
- export declare const MAX_TRANSCRIPT_BYTES: number;
4
- export declare const OUTPUT_CAPTURE_BANNER = "<!-- Best-effort secret redaction only. Connection strings, PEM blocks, and PII are NOT caught. -->";
5
- export interface SubagentOutputCaptureInput {
6
- sessionId: string | undefined;
7
- subagentId: string | undefined;
8
- isSubagentFork: boolean;
9
- model: string | undefined;
10
- }
11
- export declare function shouldCaptureSubagentOutput(input: SubagentOutputCaptureInput): boolean;
12
- export declare function sanitizeRecordText(text: string): string;
13
- export declare function buildOutputHeader(input: SubagentOutputCaptureInput): string;
14
- export declare function buildRecord(kind: 'say' | 'tool' | 'end', detail: {
15
- text?: string;
16
- toolName?: string;
17
- toolInput?: string;
18
- reason?: string;
19
- }): string;
20
- export interface SubagentOutputRecorder {
21
- observe(event: OutputEvent): void;
22
- end(reason: string): void;
23
- }
24
- export declare function createSubagentOutputRecorder(input: SubagentOutputCaptureInput): SubagentOutputRecorder | null;
@@ -1,22 +0,0 @@
1
- export declare const MAX_CAPTURED_PROMPT_BYTES: number;
2
- export declare const PROMPT_CAPTURE_BANNER = "<!-- Best-effort secret redaction only. Connection strings, PEM blocks, and PII are NOT caught. -->";
3
- export interface CaptureSubagentPromptInput {
4
- sessionId: string | undefined;
5
- subagentId: string | undefined;
6
- isSubagentFork: boolean;
7
- model: string | undefined;
8
- turn: number;
9
- prompt: string;
10
- }
11
- export declare function shouldCaptureSubagentPrompt(input: CaptureSubagentPromptInput): boolean;
12
- export declare function truncateToBytes(text: string, maxBytes: number): {
13
- text: string;
14
- originalBytes: number;
15
- truncated: boolean;
16
- };
17
- export declare function buildPromptDocument(input: CaptureSubagentPromptInput, capture: {
18
- text: string;
19
- originalBytes: number;
20
- truncated: boolean;
21
- }): string;
22
- export declare function captureSubagentPrompt(input: CaptureSubagentPromptInput): Promise<void>;
@@ -1,425 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * postinstall.mjs — Runs after `npm install` to check whether the npm bin
4
- * directory is on PATH and prints a remediation hint if not.
5
- *
6
- * Named export `detectPathGap` is a pure function for unit testing.
7
- * The main block is guarded by an import.meta.url check so this file can be
8
- * imported in tests without triggering the subprocess/print side-effects.
9
- *
10
- * Always exits 0 — never fails an install.
11
- */
12
-
13
- import { execSync, execFileSync } from 'node:child_process';
14
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
15
- import { homedir } from 'node:os';
16
- import { join } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
-
19
- // ─── Module-scope derived paths ────────────────────────────────────────────
20
- // Computed once so restartLaunchdServices and the main block share the same
21
- // decoded, symlink-resolved package root rather than each re-deriving it.
22
- // fileURLToPath decodes percent-encoded characters (e.g. spaces → %20) that
23
- // new URL().pathname leaves encoded and that path.join then mis-handles.
24
- const _pkgRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..');
25
-
26
- // ─── Exported helpers ───────────────────────────────────────────────────────
27
-
28
- /**
29
- * Pure function. Returns true when this module is the Node.js entry-point
30
- * (i.e. run directly, not imported). Accepts the two pieces of identity as
31
- * parameters so it can be unit-tested with arbitrary values.
32
- *
33
- * Comparison uses fileURLToPath (not a raw `file://` string) so that
34
- * percent-encoded characters in the install path — e.g. `/my%20dir/` — do not
35
- * cause a false negative. realpathSync resolves symlinks in argv[1] so that
36
- * globally-installed bins (typically symlinked into /usr/local/bin) compare
37
- * equal to the real file recorded in import.meta.url.
38
- *
39
- * @param {string} metaUrl - import.meta.url of the calling module.
40
- * @param {string} [argv1] - process.argv[1]; may be absent or non-existent.
41
- * @returns {boolean}
42
- */
43
- export function isMainModule(metaUrl, argv1) {
44
- if (!argv1) return false;
45
- try {
46
- return fileURLToPath(metaUrl) === realpathSync(argv1);
47
- } catch {
48
- // argv[1] does not exist on disk (e.g. a piped script, a test runner
49
- // that synthesises a path). Fall back to a plain string comparison so
50
- // the guard never throws at module evaluation time.
51
- return fileURLToPath(metaUrl) === argv1;
52
- }
53
- }
54
-
55
- /**
56
- * Pure function. Determines whether the npm bin directory is on PATH.
57
- *
58
- * @param {string} prefix - Result of `npm config get prefix` (may have trailing slash)
59
- * @param {string} pathEnv - The PATH string to check against (e.g. process.env.PATH)
60
- * @returns {{ onPath: boolean, binDir: string }}
61
- */
62
- export function detectPathGap(prefix, pathEnv) {
63
- const normalizedPrefix = prefix.trim().replace(/\/$/, '');
64
- const binDir = `${normalizedPrefix}/bin`;
65
- const pathParts = (pathEnv ?? '').split(':').map((p) => p.replace(/\/$/, ''));
66
- const onPath = pathParts.includes(binDir);
67
- return { onPath, binDir };
68
- }
69
-
70
- /**
71
- * Pure function. Determines whether this postinstall run is part of a global
72
- * package install (`npm install -g` / `pnpm add -g`) as opposed to a local
73
- * dev-checkout `pnpm install` or a worktree install.
74
- *
75
- * Detection uses two independent signals and fails CLOSED toward NOT-global
76
- * (i.e. no restart) whenever either signal is uncertain:
77
- *
78
- * 1. npm_config_global env var — npm and pnpm both set this to the string
79
- * "true" for every global install. A local `pnpm install` inside a repo
80
- * never sets it. This is the primary, cheapest signal.
81
- *
82
- * 2. git-worktree presence check — if the package root contains a .git
83
- * entry (file or directory) it is a source checkout or managed worktree,
84
- * never a globally-installed package. A global install lands under
85
- * node_modules inside the global npm prefix, never inside a git tree.
86
- * This is a belt-and-suspenders guard: even if npm_config_global were
87
- * wrong or missing, a .git marker unambiguously rules out a global install.
88
- *
89
- * History: Daemon was unconditionally restarted on every postinstall.
90
- * A cron job that ran `pnpm install` inside a repo worktree triggered this
91
- * path and restarted the running daemon mid-session, orphaning 438 subagents
92
- * and leaving the daemon stopped. The guard introduced here ensures restarts
93
- * occur ONLY for genuine global upgrades. See PR #2195.
94
- *
95
- * @param {string} pkgRoot - Absolute path to the package root.
96
- * @param {object} [env] - Environment variable bag; defaults to process.env.
97
- * @param {function} [existsFn] - Injectable fs.existsSync for testing.
98
- * @returns {boolean} true only when both signals confirm a global install.
99
- */
100
- export function isGlobalInstall(pkgRoot, env = process.env, existsFn = existsSync) {
101
- // Signal 1: npm/pnpm lifecycle env var. Absent or non-"true" → not global.
102
- if (env['npm_config_global'] !== 'true') return false;
103
-
104
- // Signal 2: .git marker. Present → source checkout / worktree → not global.
105
- const gitMarker = join(pkgRoot, '.git');
106
- if (existsFn(gitMarker)) return false;
107
-
108
- return true;
109
- }
110
-
111
- /**
112
- * Pure function. Attempts to SIGTERM a daemon whose PID is written in the
113
- * given file. All errors are silently discarded — this is best-effort cleanup
114
- * that must never fail an install.
115
- *
116
- * NOTE: the install flow no longer calls this. SIGTERM'ing a manually-started
117
- * bot left it dead with nothing to relaunch it (postinstall cannot reliably
118
- * spawn a detached long-lived process). The main block now NOTIFIES instead
119
- * (see isManualBotRunning). Retained as a tested, reusable utility.
120
- *
121
- * @param {string} pidFilePath - Full path to the PID file to read.
122
- * @param {function} [killFn] - Injectable kill function; defaults to process.kill.
123
- * Pass a stub in tests to avoid needing process.kill.
124
- */
125
- export function killStaleDaemon(pidFilePath, killFn = process.kill) {
126
- try {
127
- const raw = readFileSync(pidFilePath, 'utf8').trim();
128
- const pid = parseInt(raw, 10);
129
- if (!Number.isFinite(pid) || pid <= 0) return;
130
- killFn(pid, 'SIGTERM');
131
- } catch {
132
- // File missing, non-numeric PID, ESRCH, EPERM, any other error — discard.
133
- }
134
- }
135
-
136
- /**
137
- * Read-only probe: is a *manually-started* telegram bot still alive? Only the
138
- * `afk telegram start` path records a child PID in bot.pid; a launchd-supervised
139
- * bot does NOT (launchd owns its PID — see src/service/launchd/paths.ts), so a
140
- * non-null result here always means a manual bot — exactly the instance the
141
- * launchd kickstart below cannot reach.
142
- *
143
- * Unlike the manager's isRunning(), this does NOT unlink a stale PID file:
144
- * postinstall must not mutate runtime state. Returns the live PID, or null when
145
- * the file is missing/malformed or the process is gone.
146
- *
147
- * @param {string} pidFilePath
148
- * @param {function} [probeFn] - Injectable existence probe; defaults to process.kill.
149
- * @returns {number|null}
150
- */
151
- export function isManualBotRunning(pidFilePath, probeFn = process.kill) {
152
- try {
153
- const raw = readFileSync(pidFilePath, 'utf8').trim();
154
- const pid = parseInt(raw, 10);
155
- if (!Number.isFinite(pid) || pid <= 0) return null;
156
- probeFn(pid, 0); // signal 0 = existence check; delivers no signal
157
- return pid;
158
- } catch {
159
- // File missing, non-numeric PID, ESRCH, EPERM — treat as not-running.
160
- return null;
161
- }
162
- }
163
-
164
- /**
165
- * Pure function. Encapsulates the platform + isGlobalInstall gate that guards
166
- * daemon restarts in the main block. Extracted so unit tests can exercise the
167
- * decision logic without running the `isMain` block.
168
- *
169
- * The function returns the array of labels that were restarted (empty when the
170
- * guard fires and the restart is skipped), mirroring restartLaunchdServices.
171
- *
172
- * @param {object} [opts]
173
- * @param {string} [opts.platform] - Platform string; defaults to process.platform.
174
- * @param {object} [opts.env] - Environment variable bag; defaults to process.env.
175
- * @param {string} [opts.pkgRoot] - Absolute path to the package root.
176
- * @param {function} [opts.existsFn] - Injectable fs.existsSync for isGlobalInstall.
177
- * @param {function} [opts.restartFn] - Injectable restartLaunchdServices; defaults to the module export.
178
- * @returns {string[]} Labels that were restarted, or [] when the guard fires.
179
- */
180
- export function maybeRestartServices(opts = {}) {
181
- const platform = opts.platform ?? process.platform;
182
- const env = opts.env ?? process.env;
183
- const pkgRoot =
184
- opts.pkgRoot ?? fileURLToPath(new URL('..', import.meta.url));
185
- const existsFn = opts.existsFn ?? existsSync;
186
- const restartFn = opts.restartFn ?? restartLaunchdServices;
187
-
188
- if (platform !== 'darwin') return [];
189
- if (!isGlobalInstall(pkgRoot, env, existsFn)) return [];
190
-
191
- try {
192
- return restartFn({ labels: ['com.afk.daemon'] });
193
- } catch {
194
- // restartLaunchdServices is already fail-open; belt-and-suspenders.
195
- return [];
196
- }
197
- }
198
-
199
- /**
200
- * Restart installed AFK launchd services so they pick up the just-installed
201
- * code. A long-running Node process keeps the OLD module graph in memory after
202
- * `npm install -g` overwrites the files on disk; only a restart swaps it.
203
- * `launchctl kickstart -k` kills and relaunches the job against the (now
204
- * updated) on-disk entrypoint.
205
- *
206
- * When dist/cli.mjs is available, this function delegates to
207
- * `node <dist>/cli.mjs service restart <name>` which internally upgrades
208
- * the plist (if stale) and uses the correct launchctl reload strategy
209
- * (bootout+bootstrap for an upgraded plist, kickstart for unchanged).
210
- * Without the CLI, falls back to raw `launchctl kickstart -k`.
211
- * The restart is best-effort — a failure is swallowed so the install never fails.
212
- *
213
- * Fail-open and best-effort: a service whose plist is absent is skipped (never
214
- * installed as a service); a launchctl error (job not loaded, launchctl wedged)
215
- * is swallowed so the install never fails. macOS only — the caller gates on
216
- * platform; elsewhere ~/Library/LaunchAgents won't exist so nothing restarts.
217
- *
218
- * Invariant: the daemon is NOT stateless. It may be mid-run on one or more
219
- * scheduled agent sessions. This function MUST only be called from a global
220
- * package upgrade path (verified by isGlobalInstall()) — never from a local
221
- * `pnpm install` inside a source checkout or worktree. The caller in the main
222
- * block enforces this via isGlobalInstall(); callers in tests must pass stubs
223
- * for execFn/restartFn so real launchctl is never invoked.
224
- *
225
- * Label / path / domain conventions mirror src/service/launchd/paths.ts
226
- * (labelFor → `com.afk.<name>`, plist under ~/Library/LaunchAgents, guiDomain →
227
- * `gui/<uid>`). Kept in sync by hand — this plain .mjs cannot import the
228
- * compiled TS helpers.
229
- *
230
- * @param {object} [opts]
231
- * @param {string} [opts.home] - Home dir; defaults to os.homedir().
232
- * @param {number} [opts.uid] - Numeric uid; defaults to process.getuid().
233
- * @param {string[]} [opts.labels] - launchd labels to consider.
234
- * @param {function} [opts.existsFn] - Injectable plist existence check.
235
- * @param {function} [opts.execFn] - Injectable launchctl runner; receives the argv array.
236
- * @param {function} [opts.restartFn] - Injectable CLI restart runner; receives (node, cliMjs, name).
237
- * Defaults to execFileSync. Pass a no-op stub in tests.
238
- * @returns {string[]} labels that were successfully restarted.
239
- */
240
- export function restartLaunchdServices(opts = {}) {
241
- const home = opts.home ?? homedir();
242
- const uid =
243
- opts.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 501);
244
- const labels = opts.labels ?? ['com.afk.telegram', 'com.afk.daemon'];
245
- const existsFn = opts.existsFn ?? existsSync;
246
- const execFn =
247
- opts.execFn ??
248
- ((argv) =>
249
- execFileSync('launchctl', argv, {
250
- stdio: ['ignore', 'ignore', 'ignore'],
251
- timeout: 8000,
252
- }));
253
-
254
- // Resolve the path to dist/cli.mjs relative to this script so we can invoke
255
- // `afk service upgrade <name>` via the just-installed compiled CLI. This is
256
- // the only way a plain .mjs postinstall script can reach the compiled TS
257
- // helpers — it cannot import them directly.
258
- //
259
- // __dirname equivalent for ESM: derive from import.meta.url.
260
- // scripts/postinstall.mjs → dist/cli.mjs (package root / dist/).
261
- // fileURLToPath is used (not .pathname) so percent-encoded characters in the
262
- // path are decoded correctly — e.g. a space-containing install dir on macOS.
263
- const pkgRoot = _pkgRoot;
264
- const cliMjs = join(pkgRoot, 'dist', 'cli.mjs');
265
-
266
- // Invariant: `afk service restart` is the correct single command — it
267
- // internally runs upgradeService() and then chooses bootout+bootstrap
268
- // (when the plist was rewritten) or kickstart -k (when unchanged).
269
- // Calling upgrade then kickstart separately would leave the old launchd
270
- // in-memory definition active even after the plist file was rewritten.
271
- const restartFn =
272
- opts.restartFn ??
273
- ((node, cli, name) =>
274
- execFileSync(node, [cli, 'service', 'restart', name], {
275
- stdio: ['ignore', 'ignore', 'ignore'],
276
- timeout: 15000,
277
- }));
278
-
279
- const restarted = [];
280
- for (const label of labels) {
281
- const plist = join(home, 'Library', 'LaunchAgents', `${label}.plist`);
282
- if (!existsFn(plist)) continue; // not installed as a service
283
-
284
- // Derive the short service name from the reverse-DNS label (mirrors labelFor()).
285
- const name = label.replace(/^com\.afk\./, '');
286
-
287
- // Best-effort restart via the compiled CLI. `afk service restart`
288
- // handles plist upgrade + the correct launchctl reload strategy
289
- // (bootout+bootstrap when upgraded, kickstart when unchanged).
290
- // Falls back to raw kickstart when dist/cli.mjs is unavailable
291
- // (e.g. source checkout without a build).
292
- let restarted_via_cli = false;
293
- if (existsFn(cliMjs)) {
294
- try {
295
- restartFn(process.execPath, cliMjs, name);
296
- restarted_via_cli = true;
297
- restarted.push(label);
298
- } catch {
299
- // CLI restart failed — fall through to raw kickstart.
300
- }
301
- }
302
-
303
- if (!restarted_via_cli) {
304
- try {
305
- execFn(['kickstart', '-k', `gui/${uid}/${label}`]);
306
- restarted.push(label);
307
- } catch {
308
- // Job not loaded, or launchctl errored — skip; install must not fail.
309
- }
310
- }
311
- }
312
- return restarted;
313
- }
314
-
315
- // ─── Main block ─────────────────────────────────────────────────────────────
316
- // Guard: only run when executed directly (not when imported by tests).
317
- // isMainModule() handles percent-encoded paths and symlinked global bins
318
- // correctly; the raw `file://${process.argv[1]}` comparison it replaces
319
- // would return false when the install path contains spaces or other chars
320
- // that import.meta.url encodes as percent-sequences.
321
- const isMain =
322
- typeof process !== 'undefined' &&
323
- typeof process.argv !== 'undefined' &&
324
- isMainModule(import.meta.url, process.argv[1]);
325
-
326
- if (isMain) {
327
- // Only meaningful on macOS/Linux where PATH-based installs are common.
328
- if (process.platform === 'win32') {
329
- process.exit(0);
330
- }
331
-
332
- try {
333
- const prefix = execSync('npm config get prefix', {
334
- timeout: 2000,
335
- encoding: 'utf-8',
336
- stdio: ['ignore', 'pipe', 'ignore'],
337
- });
338
-
339
- const { onPath, binDir } = detectPathGap(prefix, process.env['PATH'] ?? '');
340
-
341
- if (!onPath) {
342
- const shell = process.env['SHELL'] ?? '';
343
- const rcFile = shell.includes('zsh')
344
- ? '~/.zshrc'
345
- : shell.includes('fish')
346
- ? '~/.config/fish/config.fish'
347
- : '~/.bashrc';
348
-
349
- const exportLine =
350
- shell.includes('fish')
351
- ? `fish_add_path ${binDir}`
352
- : `export PATH="${binDir}:$PATH"`;
353
-
354
- process.stdout.write(
355
- [
356
- '',
357
- '┌─────────────────────────────────────────────────────────────┐',
358
- '│ agent-afk: npm bin directory is not on your PATH │',
359
- '│ │',
360
- `│ Run this to fix it: │`,
361
- `│ echo '${exportLine}' >> ${rcFile}`,
362
- '│ source ' + rcFile + ' │',
363
- '│ │',
364
- '│ Or add this line to your shell profile manually: │',
365
- `│ ${exportLine}`,
366
- '└─────────────────────────────────────────────────────────────┘',
367
- '',
368
- ].join('\n'),
369
- );
370
- }
371
- } catch {
372
- // execSync failed (npm not found, timeout, etc.) — silently ignore.
373
- }
374
-
375
- // Bring already-running AFK services onto the just-installed code. A long
376
- // running process keeps the old module graph in memory after npm overwrites
377
- // the files on disk — only a restart swaps it.
378
- //
379
- // Invariant: the telegram service is EXCLUDED from postinstall restart.
380
- // `launchctl kickstart -k` sends SIGTERM which kills all active sessions
381
- // mid-turn — the relaunched binary starts cold and cannot resume them. The
382
- // telegram bot has its own version-drift watchdog (stats-ticker.ts) that
383
- // detects the on-disk version mismatch within 5 minutes and exits ONLY when
384
- // no session is mid-turn (or after a bounded deferral window). Restarting it
385
- // here bypasses that session-safety logic and is the root cause of "sessions
386
- // that work but never respond" — see telegram-stuck-diagnosis.md.
387
- //
388
- // Invariant: the daemon may be mid-run on scheduled agent sessions and is
389
- // NOT safe to restart unconditionally. Service restart is only correct for a
390
- // global package upgrade (`npm install -g`). A local `pnpm install` inside a
391
- // source checkout or managed worktree must NEVER trigger a restart — doing so
392
- // kills any in-flight sessions owned by that daemon. The isGlobalInstall()
393
- // guard inside maybeRestartServices enforces this; it fails closed toward NOT
394
- // restarting whenever either detection signal is absent or ambiguous.
395
- try {
396
- const restarted = maybeRestartServices({ pkgRoot: _pkgRoot });
397
- if (restarted.length > 0) {
398
- const names = restarted.map((l) => l.replace(/^com\.afk\./, '')).join(', ');
399
- process.stdout.write(
400
- `\n↻ Restarted AFK service(s) onto the new version: ${names}\n`,
401
- );
402
- }
403
- } catch {
404
- // maybeRestartServices is already fail-open; belt-and-suspenders.
405
- }
406
- // 2. A manually-started telegram bot (`afk telegram start`) is not supervised
407
- // by launchd, so we cannot relaunch it safely from an npm lifecycle script.
408
- // Earlier versions SIGTERM'd it here, which left it dead with nothing to
409
- // bring it back. Notify instead so the user can restart it deliberately.
410
- try {
411
- const afkHome = process.env['AFK_HOME'] ?? join(homedir(), '.afk');
412
- const botPidPath = join(afkHome, 'state', 'telegram', 'bot.pid');
413
- const manualPid = isManualBotRunning(botPidPath);
414
- if (manualPid !== null) {
415
- process.stdout.write(
416
- `\n⚠ A manually-started telegram bot (PID ${manualPid}) is still running the previous version.\n` +
417
- ` Restart it to apply the update: afk telegram restart\n`,
418
- );
419
- }
420
- } catch {
421
- // Best-effort notice — never block the install.
422
- }
423
-
424
- process.exit(0);
425
- }