@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/feed.js +77 -4
  4. package/dist/commands/hooks.js +22 -6
  5. package/dist/commands/perf.d.ts +14 -0
  6. package/dist/commands/perf.js +221 -0
  7. package/dist/commands/routines.js +30 -24
  8. package/dist/commands/secrets.d.ts +43 -4
  9. package/dist/commands/secrets.js +217 -32
  10. package/dist/commands/send.d.ts +5 -1
  11. package/dist/commands/send.js +1 -1
  12. package/dist/commands/sessions-picker.js +70 -1
  13. package/dist/index.js +18 -3
  14. package/dist/lib/activity.d.ts +11 -1
  15. package/dist/lib/activity.js +1 -0
  16. package/dist/lib/catchup.d.ts +105 -0
  17. package/dist/lib/catchup.js +160 -0
  18. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  19. package/dist/lib/channels/providers/desktop.js +132 -0
  20. package/dist/lib/channels/providers/index.js +2 -0
  21. package/dist/lib/daemon.js +74 -13
  22. package/dist/lib/events.d.ts +12 -0
  23. package/dist/lib/events.js +122 -9
  24. package/dist/lib/exec.js +10 -0
  25. package/dist/lib/feed-broadcast.d.ts +47 -0
  26. package/dist/lib/feed-broadcast.js +65 -1
  27. package/dist/lib/feed-post.d.ts +10 -0
  28. package/dist/lib/feed-post.js +1 -1
  29. package/dist/lib/feed.d.ts +47 -1
  30. package/dist/lib/feed.js +38 -0
  31. package/dist/lib/hooks/cache.d.ts +2 -0
  32. package/dist/lib/hooks/cache.js +24 -4
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  34. package/dist/lib/overdue.d.ts +14 -0
  35. package/dist/lib/overdue.js +37 -1
  36. package/dist/lib/perf/db.d.ts +25 -0
  37. package/dist/lib/perf/db.js +290 -0
  38. package/dist/lib/perf/spool.d.ts +18 -0
  39. package/dist/lib/perf/spool.js +79 -0
  40. package/dist/lib/perf/types.d.ts +45 -0
  41. package/dist/lib/perf/types.js +2 -0
  42. package/dist/lib/routines-project.js +6 -0
  43. package/dist/lib/routines.d.ts +30 -1
  44. package/dist/lib/routines.js +11 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/secrets/list-filter.d.ts +94 -0
  48. package/dist/lib/secrets/list-filter.js +245 -0
  49. package/dist/lib/session/digest.d.ts +7 -0
  50. package/dist/lib/session/digest.js +29 -1
  51. package/dist/lib/session/discover.d.ts +1 -2
  52. package/dist/lib/session/discover.js +7 -24
  53. package/dist/lib/session/highlights.d.ts +82 -0
  54. package/dist/lib/session/highlights.js +251 -0
  55. package/dist/lib/session/parse.js +23 -1
  56. package/dist/lib/session/relative-time.d.ts +14 -0
  57. package/dist/lib/session/relative-time.js +36 -0
  58. package/dist/lib/session/render.d.ts +7 -0
  59. package/dist/lib/session/render.js +87 -17
  60. package/dist/lib/session/types.d.ts +4 -1
  61. package/dist/lib/startup/command-registry.d.ts +1 -0
  62. package/dist/lib/startup/command-registry.js +2 -0
  63. package/dist/lib/state.d.ts +9 -0
  64. package/dist/lib/state.js +11 -0
  65. package/package.json +3 -1
@@ -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 detection: any enabled recurring job whose most-recent expected
481
- // fire is older than its most-recent recorded run is overdue. Happens when
482
- // the laptop was off or the daemon crashed through a scheduled fire.
483
- // We log it and pop a native notification the user can review with
484
- // `agents routines list` and run them with `agents routines catchup`.
485
- try {
486
- const overdue = detectOverdueJobs();
487
- if (overdue.length > 0) {
488
- log('WARN', `${overdue.length} routine(s) overdue:`);
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
- catch (err) {
497
- log('ERROR', `Overdue detection failed: ${err.message}`);
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);
@@ -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;
@@ -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: Date.now() - start,
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: Date.now() - start,
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: Date.now() - start,
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: Date.now() - start,
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
- ...payload,
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: Date.now() - start,
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: Date.now() - start,
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
- return link ? `${head}\n${link}` : head;
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
  /**
@@ -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
  /**
@@ -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,
@@ -38,7 +38,20 @@ export interface OpenBlock {
38
38
  runtime: string;
39
39
  ts: string;
40
40
  questions: BlockQuestion[];
41
- kind?: 'question' | 'notification' | 'control';
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. */