@phnx-labs/agents-cli 1.20.90 → 1.20.91
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 +121 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- package/package.json +3 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -18,6 +18,7 @@ import { JobScheduler } from './scheduler.js';
|
|
|
18
18
|
import { MonitorEngine } from './monitors/engine.js';
|
|
19
19
|
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
20
20
|
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
21
|
+
import { runCatchup } from './catchup.js';
|
|
21
22
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
|
|
22
23
|
import { BrowserService } from './browser/service.js';
|
|
23
24
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
@@ -32,6 +33,14 @@ const LOG_ROTATE_COUNT = 3;
|
|
|
32
33
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
33
34
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
34
35
|
const MONITOR_TICK_MS = 60_000;
|
|
36
|
+
/**
|
|
37
|
+
* How often to re-scan for missed fires. Deliberately slower than the monitor
|
|
38
|
+
* tick: detection walks a week of cron occurrences per routine
|
|
39
|
+
* (`previousExpectedFire`), and a fire that was already missed is not urgent to
|
|
40
|
+
* the second — five minutes bounds the cost while still recovering from a
|
|
41
|
+
* wedge or an OS suspend the process survived.
|
|
42
|
+
*/
|
|
43
|
+
const CATCHUP_TICK_MS = 5 * 60_000;
|
|
35
44
|
const WEDGE_THRESHOLD_TICKS = 3;
|
|
36
45
|
/**
|
|
37
46
|
* RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
|
|
@@ -477,25 +486,76 @@ export async function runDaemon() {
|
|
|
477
486
|
catch (err) {
|
|
478
487
|
log('ERROR', `Monitor engine failed to start: ${err.message}`);
|
|
479
488
|
}
|
|
480
|
-
// Backlog
|
|
481
|
-
//
|
|
482
|
-
// the
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
// Backlog recovery: any enabled recurring job whose most-recent expected fire
|
|
490
|
+
// is older than its most-recent recorded run was missed — the laptop slept,
|
|
491
|
+
// the machine was off, or the daemon crashed through the fire. croner only
|
|
492
|
+
// schedules forward from "now", so nothing replays it on its own.
|
|
493
|
+
//
|
|
494
|
+
// Every miss is RECORDED as a `missed` run and, unless the routine sets
|
|
495
|
+
// `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
|
|
496
|
+
// pass alone misses a fire lost while the daemon stayed up but its event loop
|
|
497
|
+
// was wedged, or one lost across an OS suspend that the process survived.
|
|
498
|
+
// Overlap guard, same shape as runSessionSync/runHealCheck below. A pass
|
|
499
|
+
// awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
|
|
500
|
+
// block for a while, so a slow pass could still be working when the next tick
|
|
501
|
+
// fires. Both passes would then see a job the first has not yet reached as
|
|
502
|
+
// overdue — the miss is recorded before the await, but only for jobs already
|
|
503
|
+
// processed — and spawn it twice. The idempotency of the `missed` record
|
|
504
|
+
// guards across passes, not within one that is mid-flight.
|
|
505
|
+
let catchingUp = false;
|
|
506
|
+
const catchupPass = async () => {
|
|
507
|
+
if (catchingUp)
|
|
508
|
+
return;
|
|
509
|
+
catchingUp = true;
|
|
510
|
+
try {
|
|
511
|
+
const overdue = detectOverdueJobs();
|
|
512
|
+
if (overdue.length === 0)
|
|
513
|
+
return;
|
|
514
|
+
log('WARN', `${overdue.length} routine(s) missed their fire:`);
|
|
489
515
|
for (const job of overdue) {
|
|
490
516
|
const last = job.lastRanAt ? job.lastRanAt.toISOString() : 'never';
|
|
491
517
|
log('WARN', ` ${job.name} -- expected ${job.expectedAt.toISOString()}, last ran ${last}`);
|
|
492
518
|
}
|
|
493
519
|
notifyOverdue(overdue);
|
|
520
|
+
const outcomes = await runCatchup({ overdue });
|
|
521
|
+
for (const o of outcomes) {
|
|
522
|
+
// Every variant handled explicitly: a catch-all else would log the
|
|
523
|
+
// benign 'claimed-elsewhere' (another process legitimately won the
|
|
524
|
+
// claim) as an ERROR with an undefined reason.
|
|
525
|
+
switch (o.result) {
|
|
526
|
+
case 'ran':
|
|
527
|
+
log('INFO', `Caught up '${o.name}' (run: ${o.runId})`);
|
|
528
|
+
break;
|
|
529
|
+
case 'recorded':
|
|
530
|
+
log('INFO', `Recorded missed fire for '${o.name}' (catchup disabled)`);
|
|
531
|
+
break;
|
|
532
|
+
case 'claimed-elsewhere':
|
|
533
|
+
log('INFO', `Missed fire for '${o.name}' already claimed by another catchup`);
|
|
534
|
+
break;
|
|
535
|
+
case 'error':
|
|
536
|
+
log('ERROR', `Catchup for '${o.name}' failed: ${o.error}`);
|
|
537
|
+
break;
|
|
538
|
+
default: {
|
|
539
|
+
// Compile-time exhaustiveness: a new CatchupOutcome variant fails
|
|
540
|
+
// typecheck here rather than silently landing in the wrong log level,
|
|
541
|
+
// which is exactly how 'claimed-elsewhere' was first missed.
|
|
542
|
+
const unhandled = o.result;
|
|
543
|
+
log('ERROR', `Catchup for '${o.name}' returned an unhandled result: ${String(unhandled)}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
494
547
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
548
|
+
catch (err) {
|
|
549
|
+
log('ERROR', `Catchup pass failed: ${err.message}`);
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
// finally, not a tail assignment: the no-overdue path returns early, and a
|
|
553
|
+
// throw must not leave the guard latched shut for the daemon's lifetime.
|
|
554
|
+
catchingUp = false;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
await catchupPass();
|
|
558
|
+
const catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
|
|
499
559
|
// Before the BrowserService comes up, reap browser + tunnel processes
|
|
500
560
|
// spawned by previous daemons that are no longer alive. Without this,
|
|
501
561
|
// a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
|
|
@@ -834,6 +894,7 @@ export async function runDaemon() {
|
|
|
834
894
|
monitorEngine.stop();
|
|
835
895
|
await browserIPC.stop();
|
|
836
896
|
clearInterval(monitorInterval);
|
|
897
|
+
clearInterval(catchupInterval);
|
|
837
898
|
clearInterval(syncInterval);
|
|
838
899
|
clearInterval(healInterval);
|
|
839
900
|
clearTimeout(healKickoff);
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -20,6 +20,14 @@ export interface EventMeta {
|
|
|
20
20
|
tz: string;
|
|
21
21
|
tzName: string;
|
|
22
22
|
hostname: string;
|
|
23
|
+
/**
|
|
24
|
+
* Normalized, joinable device id (`machine-id.ts::machineId()`) — the same key
|
|
25
|
+
* `agents devices`/session-sync use, so an event can be matched to a device.
|
|
26
|
+
* `hostname` is the raw `os.hostname()`; `machineId` is `zion` for `Zion.local`.
|
|
27
|
+
* Optional on the type so legacy records (pre-provenance-floor) and the activity
|
|
28
|
+
* stream still parse; `emit()` always stamps it on the operational log.
|
|
29
|
+
*/
|
|
30
|
+
machineId?: string;
|
|
23
31
|
platform: NodeJS.Platform;
|
|
24
32
|
arch: string;
|
|
25
33
|
pid: number;
|
|
@@ -40,6 +48,10 @@ export interface EventPayload {
|
|
|
40
48
|
agent?: string;
|
|
41
49
|
version?: string;
|
|
42
50
|
sessionId?: string;
|
|
51
|
+
/** Spawn-time join key (AGENT_LAUNCH_ID) mapping this action to its launch. */
|
|
52
|
+
launchId?: string;
|
|
53
|
+
/** The session that spawned this one (AGENTS_PARENT_SESSION_ID) — lineage edge. */
|
|
54
|
+
parentSessionId?: string;
|
|
43
55
|
cwd?: string;
|
|
44
56
|
/** Top-level command group, e.g. 'teams', 'secrets' — the audit filter key. */
|
|
45
57
|
module?: string;
|
package/dist/lib/events.js
CHANGED
|
@@ -20,6 +20,28 @@ import { parseSshConnection } from './session/provenance.js';
|
|
|
20
20
|
import { ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
21
21
|
import { getUserAgentsDir } from './state.js';
|
|
22
22
|
import { resolveActor } from './actor.js';
|
|
23
|
+
import { machineId } from './machine-id.js';
|
|
24
|
+
/** Lazy perf warehouse write — avoids a hard cycle at module load. */
|
|
25
|
+
function recordPerfTiming(payload) {
|
|
26
|
+
try {
|
|
27
|
+
// Dynamic import keeps events.ts free of a load-time dependency on perf/db.
|
|
28
|
+
void import('./perf/spool.js').then(({ recordSample }) => {
|
|
29
|
+
recordSample({
|
|
30
|
+
kind: 'perf.timing',
|
|
31
|
+
label: payload.label,
|
|
32
|
+
durationMs: payload.durationMs,
|
|
33
|
+
status: payload.status,
|
|
34
|
+
agent: payload.agent,
|
|
35
|
+
agentVersion: payload.version,
|
|
36
|
+
sessionId: payload.sessionId,
|
|
37
|
+
cwd: payload.cwd,
|
|
38
|
+
});
|
|
39
|
+
}).catch(() => { });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// fail soft
|
|
43
|
+
}
|
|
44
|
+
}
|
|
23
45
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
24
46
|
// Resolved lazily: events.ts is imported transitively by most CLI surfaces, and
|
|
25
47
|
// import itself must stay side-effect free. Tests may override the exact path.
|
|
@@ -118,7 +140,7 @@ const SECRET_PATH = /\/(secrets|credentials|\.env|user\.yaml)\b/i;
|
|
|
118
140
|
const SENSITIVE_ARG_NAME = /password|secret|token|key|api[-_]?key|auth/i;
|
|
119
141
|
const SENSITIVE_PAYLOAD_KEY = /password|secret|token|api[-_]?key|auth/i;
|
|
120
142
|
const RESERVED_META_KEYS = new Set([
|
|
121
|
-
'ts', 'tz', 'tzName', 'hostname', 'platform', 'arch', 'pid', 'ppid',
|
|
143
|
+
'ts', 'tz', 'tzName', 'hostname', 'machineId', 'platform', 'arch', 'pid', 'ppid',
|
|
122
144
|
'event', 'level', 'caller', 'session', 'osUser', 'transport', 'sshClientIp',
|
|
123
145
|
'actor', 'kind',
|
|
124
146
|
]);
|
|
@@ -288,6 +310,24 @@ function auditOrigin() {
|
|
|
288
310
|
};
|
|
289
311
|
return _origin;
|
|
290
312
|
}
|
|
313
|
+
/** This machine's normalized device id, resolved once — it can't change mid-process. */
|
|
314
|
+
let _machineId;
|
|
315
|
+
function cachedMachineId() {
|
|
316
|
+
return (_machineId ??= machineId());
|
|
317
|
+
}
|
|
318
|
+
function resolveProvenance(env = process.env) {
|
|
319
|
+
const p = {};
|
|
320
|
+
const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
|
|
321
|
+
if (sessionId)
|
|
322
|
+
p.sessionId = sessionId;
|
|
323
|
+
if (env.AGENTS_AGENT_NAME)
|
|
324
|
+
p.agent = env.AGENTS_AGENT_NAME;
|
|
325
|
+
if (env.AGENT_LAUNCH_ID)
|
|
326
|
+
p.launchId = env.AGENT_LAUNCH_ID;
|
|
327
|
+
if (env.AGENTS_PARENT_SESSION_ID)
|
|
328
|
+
p.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
|
|
329
|
+
return p;
|
|
330
|
+
}
|
|
291
331
|
// ─── Core API ─────────────────────────────────────────────────────────────────
|
|
292
332
|
/**
|
|
293
333
|
* Emit a structured event to the append-only audit log.
|
|
@@ -303,11 +343,14 @@ export function emit(event, payload = {}) {
|
|
|
303
343
|
const caller = detectCaller();
|
|
304
344
|
const safePayload = sanitizePayload(payload);
|
|
305
345
|
const record = {
|
|
346
|
+
// Provenance floor first: env-sourced defaults an explicit payload overrides.
|
|
347
|
+
...resolveProvenance(),
|
|
306
348
|
...safePayload,
|
|
307
349
|
ts: new Date().toISOString(),
|
|
308
350
|
tz: getTimezoneOffset(),
|
|
309
351
|
tzName: getTimezoneName(),
|
|
310
352
|
hostname: os.hostname(),
|
|
353
|
+
machineId: cachedMachineId(),
|
|
311
354
|
platform: os.platform(),
|
|
312
355
|
arch: os.arch(),
|
|
313
356
|
pid: process.pid,
|
|
@@ -375,22 +418,42 @@ export function time(label, fn, payload = {}) {
|
|
|
375
418
|
const start = Date.now();
|
|
376
419
|
try {
|
|
377
420
|
const result = fn();
|
|
421
|
+
const durationMs = Date.now() - start;
|
|
378
422
|
emit('perf.timing', {
|
|
379
423
|
...payload,
|
|
380
424
|
label,
|
|
381
|
-
durationMs
|
|
425
|
+
durationMs,
|
|
382
426
|
status: 'success',
|
|
383
427
|
});
|
|
428
|
+
recordPerfTiming({
|
|
429
|
+
label,
|
|
430
|
+
durationMs,
|
|
431
|
+
status: 'success',
|
|
432
|
+
agent: payload.agent,
|
|
433
|
+
version: payload.version,
|
|
434
|
+
sessionId: payload.sessionId,
|
|
435
|
+
cwd: payload.cwd,
|
|
436
|
+
});
|
|
384
437
|
return result;
|
|
385
438
|
}
|
|
386
439
|
catch (err) {
|
|
440
|
+
const durationMs = Date.now() - start;
|
|
387
441
|
emit('perf.timing', {
|
|
388
442
|
...payload,
|
|
389
443
|
label,
|
|
390
|
-
durationMs
|
|
444
|
+
durationMs,
|
|
391
445
|
status: 'error',
|
|
392
446
|
error: err instanceof Error ? err.message : String(err),
|
|
393
447
|
});
|
|
448
|
+
recordPerfTiming({
|
|
449
|
+
label,
|
|
450
|
+
durationMs,
|
|
451
|
+
status: 'error',
|
|
452
|
+
agent: payload.agent,
|
|
453
|
+
version: payload.version,
|
|
454
|
+
sessionId: payload.sessionId,
|
|
455
|
+
cwd: payload.cwd,
|
|
456
|
+
});
|
|
394
457
|
throw err;
|
|
395
458
|
}
|
|
396
459
|
}
|
|
@@ -405,22 +468,42 @@ export async function timeAsync(label, fn, payload = {}) {
|
|
|
405
468
|
const start = Date.now();
|
|
406
469
|
try {
|
|
407
470
|
const result = await fn();
|
|
471
|
+
const durationMs = Date.now() - start;
|
|
408
472
|
emit('perf.timing', {
|
|
409
473
|
...payload,
|
|
410
474
|
label,
|
|
411
|
-
durationMs
|
|
475
|
+
durationMs,
|
|
412
476
|
status: 'success',
|
|
413
477
|
});
|
|
478
|
+
recordPerfTiming({
|
|
479
|
+
label,
|
|
480
|
+
durationMs,
|
|
481
|
+
status: 'success',
|
|
482
|
+
agent: payload.agent,
|
|
483
|
+
version: payload.version,
|
|
484
|
+
sessionId: payload.sessionId,
|
|
485
|
+
cwd: payload.cwd,
|
|
486
|
+
});
|
|
414
487
|
return result;
|
|
415
488
|
}
|
|
416
489
|
catch (err) {
|
|
490
|
+
const durationMs = Date.now() - start;
|
|
417
491
|
emit('perf.timing', {
|
|
418
492
|
...payload,
|
|
419
493
|
label,
|
|
420
|
-
durationMs
|
|
494
|
+
durationMs,
|
|
421
495
|
status: 'error',
|
|
422
496
|
error: err instanceof Error ? err.message : String(err),
|
|
423
497
|
});
|
|
498
|
+
recordPerfTiming({
|
|
499
|
+
label,
|
|
500
|
+
durationMs,
|
|
501
|
+
status: 'error',
|
|
502
|
+
agent: payload.agent,
|
|
503
|
+
version: payload.version,
|
|
504
|
+
sessionId: payload.sessionId,
|
|
505
|
+
cwd: payload.cwd,
|
|
506
|
+
});
|
|
424
507
|
throw err;
|
|
425
508
|
}
|
|
426
509
|
}
|
|
@@ -449,13 +532,22 @@ export function createTimer(label, payload = {}) {
|
|
|
449
532
|
},
|
|
450
533
|
end(endPayload = {}) {
|
|
451
534
|
const durationMs = Date.now() - start;
|
|
535
|
+
const merged = { ...payload, ...endPayload };
|
|
452
536
|
emit('perf.timing', {
|
|
453
|
-
...
|
|
454
|
-
...endPayload,
|
|
537
|
+
...merged,
|
|
455
538
|
label,
|
|
456
539
|
durationMs,
|
|
457
540
|
phases: marks,
|
|
458
541
|
});
|
|
542
|
+
recordPerfTiming({
|
|
543
|
+
label,
|
|
544
|
+
durationMs,
|
|
545
|
+
status: typeof merged.status === 'string' ? merged.status : undefined,
|
|
546
|
+
agent: merged.agent,
|
|
547
|
+
version: merged.version,
|
|
548
|
+
sessionId: merged.sessionId,
|
|
549
|
+
cwd: merged.cwd,
|
|
550
|
+
});
|
|
459
551
|
},
|
|
460
552
|
};
|
|
461
553
|
}
|
|
@@ -472,22 +564,42 @@ export function withTiming(label, fn, basePayload = {}) {
|
|
|
472
564
|
const start = Date.now();
|
|
473
565
|
try {
|
|
474
566
|
const result = await fn(...args);
|
|
567
|
+
const durationMs = Date.now() - start;
|
|
475
568
|
emit('perf.timing', {
|
|
476
569
|
...basePayload,
|
|
477
570
|
label,
|
|
478
|
-
durationMs
|
|
571
|
+
durationMs,
|
|
479
572
|
status: 'success',
|
|
480
573
|
});
|
|
574
|
+
recordPerfTiming({
|
|
575
|
+
label,
|
|
576
|
+
durationMs,
|
|
577
|
+
status: 'success',
|
|
578
|
+
agent: basePayload.agent,
|
|
579
|
+
version: basePayload.version,
|
|
580
|
+
sessionId: basePayload.sessionId,
|
|
581
|
+
cwd: basePayload.cwd,
|
|
582
|
+
});
|
|
481
583
|
return result;
|
|
482
584
|
}
|
|
483
585
|
catch (err) {
|
|
586
|
+
const durationMs = Date.now() - start;
|
|
484
587
|
emit('perf.timing', {
|
|
485
588
|
...basePayload,
|
|
486
589
|
label,
|
|
487
|
-
durationMs
|
|
590
|
+
durationMs,
|
|
488
591
|
status: 'error',
|
|
489
592
|
error: err instanceof Error ? err.message : String(err),
|
|
490
593
|
});
|
|
594
|
+
recordPerfTiming({
|
|
595
|
+
label,
|
|
596
|
+
durationMs,
|
|
597
|
+
status: 'error',
|
|
598
|
+
agent: basePayload.agent,
|
|
599
|
+
version: basePayload.version,
|
|
600
|
+
sessionId: basePayload.sessionId,
|
|
601
|
+
cwd: basePayload.cwd,
|
|
602
|
+
});
|
|
491
603
|
throw err;
|
|
492
604
|
}
|
|
493
605
|
};
|
|
@@ -756,6 +868,7 @@ export function getLogsPath() {
|
|
|
756
868
|
export function _resetForTest(overrideEventsPath) {
|
|
757
869
|
_eventsPath = overrideEventsPath;
|
|
758
870
|
_origin = undefined;
|
|
871
|
+
_machineId = undefined;
|
|
759
872
|
_chmoddedPath = undefined;
|
|
760
873
|
lastRotationCheck = 0;
|
|
761
874
|
}
|
package/dist/lib/exec.js
CHANGED
|
@@ -373,6 +373,16 @@ export function buildExecEnv(options) {
|
|
|
373
373
|
result.AGENT_SESSION_ID = options.sessionId;
|
|
374
374
|
result.AGENTS_SESSION_ID = options.sessionId;
|
|
375
375
|
}
|
|
376
|
+
// Lineage edge: the child's parent is THIS process's session (the spawner), so a
|
|
377
|
+
// sub-agent's events carry a walkable edge back to who spawned it. The event floor
|
|
378
|
+
// (events.ts::resolveProvenance) reads AGENTS_PARENT_SESSION_ID and stamps it on
|
|
379
|
+
// every event the child emits. `options.sessionId` is the CHILD's id, so read the
|
|
380
|
+
// spawner from the live env; guard a same-session resume from naming itself parent.
|
|
381
|
+
// Local-spawn scope here; forwarding it across the `--host` SSH hop is Phase 4.
|
|
382
|
+
const spawnerSessionId = process.env.AGENTS_SESSION_ID || process.env.AGENT_SESSION_ID;
|
|
383
|
+
if (spawnerSessionId && spawnerSessionId !== options.sessionId) {
|
|
384
|
+
result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
|
|
385
|
+
}
|
|
376
386
|
result.AGENTS_RUNTIME = resolveInteractive(options) ? 'terminal' : 'headless';
|
|
377
387
|
// So activity / feed posts stamp the right harness without re-detecting.
|
|
378
388
|
if (options.agent) {
|
|
@@ -28,7 +28,54 @@ export interface FeedBroadcastContext {
|
|
|
28
28
|
session?: string;
|
|
29
29
|
/** URLs attached to the post — the PR, the ticket, a shared plan. */
|
|
30
30
|
links?: string[];
|
|
31
|
+
/** Block-only: the block's stable id. Absent on a status post. */
|
|
32
|
+
blockId?: string;
|
|
33
|
+
/** Block-only: `approval` (has a safe default) or `decision` (needs a human). */
|
|
34
|
+
class?: string;
|
|
35
|
+
/** Block-only: cost-of-delay tag used by the urgency filter. */
|
|
36
|
+
cost?: string;
|
|
37
|
+
/** Block-only: the literal `agents focus <id>` command that unblocks it. */
|
|
38
|
+
focus?: string;
|
|
31
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Map an open block onto the broadcast context, so a block reaches the same sinks
|
|
42
|
+
* a post does instead of dying in the ledger.
|
|
43
|
+
*
|
|
44
|
+
* The `text` is the ask itself, front-loaded — a notification banner shows roughly
|
|
45
|
+
* two lines, and a phone message is scanned, not read. `focus` carries the literal
|
|
46
|
+
* command that unblocks it, so the message the operator receives contains the one
|
|
47
|
+
* action they have to take rather than making them go find the session.
|
|
48
|
+
*
|
|
49
|
+
* Level is always `important`: a block is by definition an agent that has stopped
|
|
50
|
+
* making progress, so there is no per-block level flag to get wrong.
|
|
51
|
+
*/
|
|
52
|
+
export declare function blockBroadcastContext(block: {
|
|
53
|
+
blockId: string;
|
|
54
|
+
sessionId: string;
|
|
55
|
+
host?: string;
|
|
56
|
+
questions?: Array<{
|
|
57
|
+
text?: string;
|
|
58
|
+
}>;
|
|
59
|
+
blockClass?: string;
|
|
60
|
+
costOfDelay?: string;
|
|
61
|
+
ticket?: string;
|
|
62
|
+
pr?: string;
|
|
63
|
+
}, extras?: {
|
|
64
|
+
project?: string;
|
|
65
|
+
agent?: string;
|
|
66
|
+
}): FeedBroadcastContext;
|
|
67
|
+
/**
|
|
68
|
+
* Why a declared block reached nobody, or undefined when it got through.
|
|
69
|
+
*
|
|
70
|
+
* Pure so the fail-loud contract is testable without driving the CLI — the
|
|
71
|
+
* original version lived inline in the command action and was consequently
|
|
72
|
+
* never covered, which is how a `--json` early-return quietly bypassed it.
|
|
73
|
+
*
|
|
74
|
+
* Only a TOTAL failure counts. One sink failing among several is a warning, not
|
|
75
|
+
* an error: the channels are redundant by design, and a dead `rush` login must
|
|
76
|
+
* not mask a delivered desktop notification.
|
|
77
|
+
*/
|
|
78
|
+
export declare function blockDeliveryFailure(blocked: boolean, outcomes: SinkOutcome[]): string | undefined;
|
|
32
79
|
export interface PlannedSink {
|
|
33
80
|
name: string;
|
|
34
81
|
argv: string[];
|
|
@@ -35,6 +35,61 @@ export function parseFeedPostLevel(raw) {
|
|
|
35
35
|
return 'important';
|
|
36
36
|
throw new Error(`Unknown --level '${raw}'. Use milestone or important.`);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Map an open block onto the broadcast context, so a block reaches the same sinks
|
|
40
|
+
* a post does instead of dying in the ledger.
|
|
41
|
+
*
|
|
42
|
+
* The `text` is the ask itself, front-loaded — a notification banner shows roughly
|
|
43
|
+
* two lines, and a phone message is scanned, not read. `focus` carries the literal
|
|
44
|
+
* command that unblocks it, so the message the operator receives contains the one
|
|
45
|
+
* action they have to take rather than making them go find the session.
|
|
46
|
+
*
|
|
47
|
+
* Level is always `important`: a block is by definition an agent that has stopped
|
|
48
|
+
* making progress, so there is no per-block level flag to get wrong.
|
|
49
|
+
*/
|
|
50
|
+
export function blockBroadcastContext(block, extras = {}) {
|
|
51
|
+
const ask = block.questions?.[0]?.text?.trim() || 'agent is blocked';
|
|
52
|
+
const links = [block.pr].filter((l) => !!l && /^https?:\/\//i.test(l));
|
|
53
|
+
return {
|
|
54
|
+
text: ask,
|
|
55
|
+
level: 'important',
|
|
56
|
+
ticket: block.ticket,
|
|
57
|
+
project: extras.project,
|
|
58
|
+
agent: extras.agent,
|
|
59
|
+
host: block.host,
|
|
60
|
+
session: block.sessionId,
|
|
61
|
+
blockId: block.blockId,
|
|
62
|
+
class: block.blockClass,
|
|
63
|
+
cost: block.costOfDelay,
|
|
64
|
+
// Short id: `agents focus` matches on a prefix, and a full uuid in a phone
|
|
65
|
+
// message is noise the operator has to skip past to reach the verb.
|
|
66
|
+
focus: `agents focus ${block.sessionId.slice(0, 8)}`,
|
|
67
|
+
...(links.length ? { links } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Why a declared block reached nobody, or undefined when it got through.
|
|
72
|
+
*
|
|
73
|
+
* Pure so the fail-loud contract is testable without driving the CLI — the
|
|
74
|
+
* original version lived inline in the command action and was consequently
|
|
75
|
+
* never covered, which is how a `--json` early-return quietly bypassed it.
|
|
76
|
+
*
|
|
77
|
+
* Only a TOTAL failure counts. One sink failing among several is a warning, not
|
|
78
|
+
* an error: the channels are redundant by design, and a dead `rush` login must
|
|
79
|
+
* not mask a delivered desktop notification.
|
|
80
|
+
*/
|
|
81
|
+
export function blockDeliveryFailure(blocked, outcomes) {
|
|
82
|
+
if (!blocked)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (outcomes.length === 0) {
|
|
85
|
+
return 'Block recorded but NOT delivered — no feed.broadcast sink configured.';
|
|
86
|
+
}
|
|
87
|
+
if (outcomes.every((o) => !o.ok)) {
|
|
88
|
+
const why = outcomes.map((o) => `${o.name}: ${o.error ?? 'failed'}`).join('; ');
|
|
89
|
+
return `Block recorded but NOT delivered — every feed.broadcast sink failed (${why}).`;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
38
93
|
const PLACEHOLDER = /\{([a-z]+)\}/g;
|
|
39
94
|
/**
|
|
40
95
|
* A human-facing one-liner for a messaging sink: what project, what happened,
|
|
@@ -45,7 +100,12 @@ const PLACEHOLDER = /\{([a-z]+)\}/g;
|
|
|
45
100
|
export function composeBroadcastMessage(ctx) {
|
|
46
101
|
const head = ctx.project ? `${ctx.project} · ${ctx.text}` : ctx.text;
|
|
47
102
|
const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
|
|
48
|
-
|
|
103
|
+
// A block's second line is the command that unblocks it. The operator reading
|
|
104
|
+
// this on a phone should not have to go find the session — the one action they
|
|
105
|
+
// must take travels with the ask. A status post has no such action, so it keeps
|
|
106
|
+
// the link there instead.
|
|
107
|
+
const tail = [ctx.focus, link].filter(Boolean);
|
|
108
|
+
return tail.length ? `${head}\n${tail.join('\n')}` : head;
|
|
49
109
|
}
|
|
50
110
|
/** The values a template may reference, resolved once per post. */
|
|
51
111
|
function templateVars(ctx) {
|
|
@@ -59,6 +119,10 @@ function templateVars(ctx) {
|
|
|
59
119
|
level: ctx.level,
|
|
60
120
|
links: ctx.links?.length ? ctx.links.join(' ') : undefined,
|
|
61
121
|
message: composeBroadcastMessage(ctx),
|
|
122
|
+
block: ctx.blockId,
|
|
123
|
+
class: ctx.class,
|
|
124
|
+
cost: ctx.cost,
|
|
125
|
+
focus: ctx.focus,
|
|
62
126
|
};
|
|
63
127
|
}
|
|
64
128
|
/**
|
package/dist/lib/feed-post.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export interface FeedPostInput {
|
|
|
9
9
|
sessionId?: string;
|
|
10
10
|
/** Generic artifacts to attach: local paths (copied for durability) or URLs. */
|
|
11
11
|
attach?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* The agent is STUCK, not merely reporting. Writes `status.blocked` instead of
|
|
14
|
+
* `status.posted`, and the caller pairs it with an OpenBlock so the ask stays
|
|
15
|
+
* answerable until someone resolves it.
|
|
16
|
+
*
|
|
17
|
+
* This is a state, not a volume: a blocked post is always broadcast at
|
|
18
|
+
* `important`, so an agent never has to decide the level as well. One thing to
|
|
19
|
+
* say is what makes the habit stick.
|
|
20
|
+
*/
|
|
21
|
+
blocked?: boolean;
|
|
12
22
|
/** Override activity root (tests). */
|
|
13
23
|
activityRoot?: string;
|
|
14
24
|
/**
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -271,7 +271,7 @@ export function postFeedStatus(input) {
|
|
|
271
271
|
});
|
|
272
272
|
const event = {
|
|
273
273
|
ts,
|
|
274
|
-
event: 'status.posted',
|
|
274
|
+
event: input.blocked ? 'status.blocked' : 'status.posted',
|
|
275
275
|
sessionId: identity.sessionId,
|
|
276
276
|
mailboxId: identity.mailboxId,
|
|
277
277
|
host: identity.host,
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -38,7 +38,20 @@ export interface OpenBlock {
|
|
|
38
38
|
runtime: string;
|
|
39
39
|
ts: string;
|
|
40
40
|
questions: BlockQuestion[];
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* How this block came to exist.
|
|
43
|
+
* question — an AskUserQuestion the harness surfaced
|
|
44
|
+
* notification — a permission/idle prompt the harness raised
|
|
45
|
+
* control — a synthetic card the feed itself computed (runaway, needy)
|
|
46
|
+
* declared — the AGENT decided it is stuck and said so (`feed post --blocked`)
|
|
47
|
+
*
|
|
48
|
+
* `declared` is the only kind that does not depend on the harness noticing
|
|
49
|
+
* anything. Every other kind is inferred from a harness event, and hook events
|
|
50
|
+
* are not portable across harnesses (only Claude fires Notification, only Codex
|
|
51
|
+
* fires PermissionRequest), so a declared block is the one signal every agent
|
|
52
|
+
* can raise — it is just a shell command.
|
|
53
|
+
*/
|
|
54
|
+
kind?: 'question' | 'notification' | 'control' | 'declared';
|
|
42
55
|
notificationType?: string;
|
|
43
56
|
ticket?: string;
|
|
44
57
|
pr?: string;
|
|
@@ -159,6 +172,39 @@ export declare function recordNotified(blockId: string, root?: string): void;
|
|
|
159
172
|
export declare function recordTerminalAnswer(blockId: string, root?: string): void;
|
|
160
173
|
/** Remove answered marker and receipts for a block (used by block removal/GC). */
|
|
161
174
|
export declare function clearBlockLifecycle(blockId: string, root?: string): void;
|
|
175
|
+
/** Identity of the agent declaring a block — the subset of `PostIdentity` it needs. */
|
|
176
|
+
export interface DeclaringAgent {
|
|
177
|
+
sessionId: string;
|
|
178
|
+
mailboxId: string;
|
|
179
|
+
host: string;
|
|
180
|
+
runtime: string;
|
|
181
|
+
}
|
|
182
|
+
export interface DeclareBlockInput {
|
|
183
|
+
/** What the agent needs from the user, front-loaded. */
|
|
184
|
+
text: string;
|
|
185
|
+
/** Answerable choices, if the ask is a pick-one. */
|
|
186
|
+
options?: string[];
|
|
187
|
+
/** A safe default makes this an approval; without one it is a decision. */
|
|
188
|
+
safeDefault?: string;
|
|
189
|
+
/** Minutes before the default-on-no-answer policy may fire. */
|
|
190
|
+
timeoutMinutes?: number;
|
|
191
|
+
ts?: string;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the block record for `agents feed post --blocked` — pure, so the shape is testable
|
|
195
|
+
* without touching the store or the broadcast layer.
|
|
196
|
+
*
|
|
197
|
+
* Class is derived, not asked for: a `--default` means the user could be absent
|
|
198
|
+
* and policy could still resolve it (approval); no default means only a human can
|
|
199
|
+
* choose (decision). `feed-policy.ts` reads exactly that distinction, so deriving
|
|
200
|
+
* it here keeps one rule in one place instead of letting a caller set a class that
|
|
201
|
+
* contradicts its own safeDefault.
|
|
202
|
+
*
|
|
203
|
+
* `costOfDelay: high` because a declared block is, by definition, an agent that
|
|
204
|
+
* has already stopped making progress — that is what makes it worth interrupting
|
|
205
|
+
* someone over, and what `feed --dispatch`'s urgency filter keys off.
|
|
206
|
+
*/
|
|
207
|
+
export declare function buildDeclaredBlock(agent: DeclaringAgent, input: DeclareBlockInput): OpenBlock;
|
|
162
208
|
/** Atomic write a block record to the feed store. Clears stale lifecycle state. */
|
|
163
209
|
export declare function publishBlock(block: OpenBlock, root?: string): void;
|
|
164
210
|
/** Read all block records. Returns them sorted by stable block filename. */
|