@phnx-labs/agents-cli 1.20.90 → 1.20.92

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 (112) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -1
@@ -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
  /**
@@ -15,7 +15,8 @@
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
17
  import { spawnSync } from 'child_process';
18
- import { appendActivityEvent, projectFromCwd, } from './activity.js';
18
+ import { appendActivityEvent, } from './activity.js';
19
+ import { resolveProjectNameForCwd, listProjectDefs } from './projects.js';
19
20
  import { getHistoryDir } from './state.js';
20
21
  import { machineId } from './machine-id.js';
21
22
  import { isValidMailboxId } from './mailbox.js';
@@ -263,7 +264,12 @@ export function postFeedStatus(input) {
263
264
  + '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
264
265
  }
265
266
  const ts = input.ts ?? new Date().toISOString();
266
- const project = projectFromCwd(identity.cwd);
267
+ // The post is written where the agent runs, so the cwd is a local path and
268
+ // gets full canonical resolution — a defined project's name wins (a post from
269
+ // any repo of a multi-repo project files under that project), else the repo
270
+ // key, matching how the timeline groups everything else. listProjectDefs is
271
+ // fail-open, so this costs one small readdir + YAML parse per post.
272
+ const project = resolveProjectNameForCwd(identity.cwd, listProjectDefs());
267
273
  const attachments = buildAttachments(input.attach, {
268
274
  copyRoot: input.attachmentsRoot ?? path.join(getHistoryDir(), 'attachments'),
269
275
  sessionId: identity.sessionId,
@@ -271,7 +277,7 @@ export function postFeedStatus(input) {
271
277
  });
272
278
  const event = {
273
279
  ts,
274
- event: 'status.posted',
280
+ event: input.blocked ? 'status.blocked' : 'status.posted',
275
281
  sessionId: identity.sessionId,
276
282
  mailboxId: identity.mailboxId,
277
283
  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. */
package/dist/lib/feed.js CHANGED
@@ -236,6 +236,44 @@ export function clearBlockLifecycle(blockId, root) {
236
236
  }
237
237
  }
238
238
  }
239
+ /**
240
+ * Build the block record for `agents feed post --blocked` — pure, so the shape is testable
241
+ * without touching the store or the broadcast layer.
242
+ *
243
+ * Class is derived, not asked for: a `--default` means the user could be absent
244
+ * and policy could still resolve it (approval); no default means only a human can
245
+ * choose (decision). `feed-policy.ts` reads exactly that distinction, so deriving
246
+ * it here keeps one rule in one place instead of letting a caller set a class that
247
+ * contradicts its own safeDefault.
248
+ *
249
+ * `costOfDelay: high` because a declared block is, by definition, an agent that
250
+ * has already stopped making progress — that is what makes it worth interrupting
251
+ * someone over, and what `feed --dispatch`'s urgency filter keys off.
252
+ */
253
+ export function buildDeclaredBlock(agent, input) {
254
+ const text = input.text.trim().replace(/\s+/g, ' ');
255
+ if (!text) {
256
+ throw new Error('Block text is empty. Usage: agents feed post "what you need from the user" --blocked');
257
+ }
258
+ const options = (input.options ?? [])
259
+ .map((label) => label.trim())
260
+ .filter(Boolean)
261
+ .map((label) => ({ label }));
262
+ return {
263
+ blockId: blockIdForSession(agent.sessionId),
264
+ sessionId: agent.sessionId,
265
+ mailboxId: agent.mailboxId,
266
+ host: agent.host,
267
+ runtime: agent.runtime,
268
+ ts: input.ts ?? new Date().toISOString(),
269
+ kind: 'declared',
270
+ questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
271
+ blockClass: input.safeDefault ? 'approval' : 'decision',
272
+ costOfDelay: 'high',
273
+ ...(input.safeDefault ? { safeDefault: input.safeDefault } : {}),
274
+ ...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
275
+ };
276
+ }
239
277
  /** Atomic write a block record to the feed store. Clears stale lifecycle state. */
240
278
  export function publishBlock(block, root) {
241
279
  const dir = root ?? getFeedDir();
@@ -26,6 +26,8 @@ export interface HookShimPaths {
26
26
  shimsDir?: string;
27
27
  cacheDir?: string;
28
28
  logsDir?: string;
29
+ /** Directory for the disposable perf warehouse + spool (default ~/.agents/.cache/perf). */
30
+ perfDir?: string;
29
31
  }
30
32
  /**
31
33
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes