@phnx-labs/agents-cli 1.22.46 → 1.22.49

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 (348) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/LICENSE +102 -182
  3. package/README.md +96 -49
  4. package/dist/bootstrap.js +4 -2
  5. package/dist/cli/command-registry.d.ts +2 -1
  6. package/dist/cli/command-registry.js +6 -2
  7. package/dist/commands/accounts.d.ts +58 -0
  8. package/dist/commands/accounts.js +243 -25
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/attach.js +1 -1
  11. package/dist/commands/auth.js +7 -5
  12. package/dist/commands/browser-sessions-picker.js +9 -7
  13. package/dist/commands/browser.js +711 -62
  14. package/dist/commands/config.d.ts +0 -1
  15. package/dist/commands/config.js +26 -24
  16. package/dist/commands/cost.d.ts +1 -1
  17. package/dist/commands/daemon.d.ts +21 -0
  18. package/dist/commands/daemon.js +147 -12
  19. package/dist/commands/detach.d.ts +13 -0
  20. package/dist/commands/detach.js +11 -8
  21. package/dist/commands/exec.js +107 -53
  22. package/dist/commands/feed-watch.d.ts +2 -0
  23. package/dist/commands/feed-watch.js +28 -0
  24. package/dist/commands/feed.js +27 -0
  25. package/dist/commands/feedback.js +3 -19
  26. package/dist/commands/focus.d.ts +14 -0
  27. package/dist/commands/focus.js +48 -5
  28. package/dist/commands/go.d.ts +3 -0
  29. package/dist/commands/go.js +16 -3
  30. package/dist/commands/insights.d.ts +1 -1
  31. package/dist/commands/insights.js +2 -2
  32. package/dist/commands/inspect.js +2 -2
  33. package/dist/commands/lease.d.ts +1 -1
  34. package/dist/commands/lease.js +7 -6
  35. package/dist/commands/mailboxes.js +4 -3
  36. package/dist/commands/menubar.js +11 -4
  37. package/dist/commands/projects.d.ts +16 -1
  38. package/dist/commands/projects.js +58 -64
  39. package/dist/commands/resume.js +1 -1
  40. package/dist/commands/route.js +4 -4
  41. package/dist/commands/routines.js +4 -3
  42. package/dist/commands/run-broadcast.d.ts +1 -1
  43. package/dist/commands/run-broadcast.js +1 -1
  44. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  45. package/dist/commands/sessions-picker-factory.js +1 -1
  46. package/dist/commands/sessions-picker.d.ts +22 -0
  47. package/dist/commands/sessions-picker.js +48 -9
  48. package/dist/commands/sessions-stop.d.ts +24 -0
  49. package/dist/commands/sessions-stop.js +66 -0
  50. package/dist/commands/sessions-trace.d.ts +82 -0
  51. package/dist/commands/sessions-trace.js +387 -0
  52. package/dist/commands/sessions.js +5 -0
  53. package/dist/commands/setup-secrets.js +23 -0
  54. package/dist/commands/share.d.ts +20 -5
  55. package/dist/commands/share.js +152 -52
  56. package/dist/commands/ssh.d.ts +15 -0
  57. package/dist/commands/ssh.js +243 -18
  58. package/dist/commands/teams.js +34 -2
  59. package/dist/commands/traces.d.ts +2 -0
  60. package/dist/commands/traces.js +184 -0
  61. package/dist/commands/view.d.ts +2 -0
  62. package/dist/commands/view.js +32 -62
  63. package/dist/commands/watchdog.js +9 -14
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +5 -0
  66. package/dist/lib/account-capabilities.d.ts +4 -0
  67. package/dist/lib/account-capabilities.js +13 -9
  68. package/dist/lib/account-registry.d.ts +2 -0
  69. package/dist/lib/account-registry.js +30 -2
  70. package/dist/lib/accounting/rotate.d.ts +9 -0
  71. package/dist/lib/accounting/rotate.js +12 -2
  72. package/dist/lib/accounting/usage.d.ts +84 -6
  73. package/dist/lib/accounting/usage.js +293 -75
  74. package/dist/lib/agent-modes.js +29 -1
  75. package/dist/lib/agent-spec/agents.d.ts +4 -3
  76. package/dist/lib/agent-spec/agents.js +25 -15
  77. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  78. package/dist/lib/analytics/mix-commands.js +2 -2
  79. package/dist/lib/auth-health.d.ts +13 -3
  80. package/dist/lib/auth-health.js +50 -8
  81. package/dist/lib/browser/devices.d.ts +19 -0
  82. package/dist/lib/browser/devices.js +23 -0
  83. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  84. package/dist/lib/browser/drivers/ssh.js +36 -9
  85. package/dist/lib/browser/ipc.d.ts +1 -1
  86. package/dist/lib/browser/ipc.js +40 -9
  87. package/dist/lib/browser/profiles.d.ts +104 -65
  88. package/dist/lib/browser/profiles.js +277 -165
  89. package/dist/lib/browser/registry.d.ts +39 -0
  90. package/dist/lib/browser/registry.js +149 -0
  91. package/dist/lib/browser/remote-control.d.ts +28 -2
  92. package/dist/lib/browser/remote-control.js +35 -3
  93. package/dist/lib/browser/resolve-target.d.ts +68 -0
  94. package/dist/lib/browser/resolve-target.js +250 -0
  95. package/dist/lib/browser/runtime-state.d.ts +44 -11
  96. package/dist/lib/browser/runtime-state.js +98 -15
  97. package/dist/lib/browser/service.d.ts +65 -9
  98. package/dist/lib/browser/service.js +196 -61
  99. package/dist/lib/browser/sessions-list.d.ts +1 -3
  100. package/dist/lib/browser/sessions-list.js +3 -16
  101. package/dist/lib/browser/task-index.d.ts +70 -0
  102. package/dist/lib/browser/task-index.js +180 -0
  103. package/dist/lib/browser/types.d.ts +12 -1
  104. package/dist/lib/byok-usage.js +2 -2
  105. package/dist/lib/claude-statusline.d.ts +29 -0
  106. package/dist/lib/claude-statusline.js +139 -0
  107. package/dist/lib/cloud/host.d.ts +1 -1
  108. package/dist/lib/cloud/host.js +1 -1
  109. package/dist/lib/cloud/rush.js +1 -1
  110. package/dist/lib/codex-policy.d.ts +11 -1
  111. package/dist/lib/codex-policy.js +20 -5
  112. package/dist/lib/computer/computer-rpc.js +2 -2
  113. package/dist/lib/computer/download.d.ts +15 -15
  114. package/dist/lib/computer/download.js +35 -98
  115. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  116. package/dist/lib/computer/ssh-tunnel.js +12 -22
  117. package/dist/lib/config-keys.d.ts +6 -11
  118. package/dist/lib/config-keys.js +18 -19
  119. package/dist/lib/crabbox/cli.d.ts +1 -1
  120. package/dist/lib/crabbox/cli.js +2 -2
  121. package/dist/lib/crabbox/lease.d.ts +9 -0
  122. package/dist/lib/crabbox/lease.js +70 -15
  123. package/dist/lib/crabbox/runtimes.js +1 -1
  124. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  125. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  126. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  127. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  128. package/dist/lib/daemon/daemon.d.ts +3 -0
  129. package/dist/lib/daemon/daemon.js +212 -444
  130. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  131. package/dist/lib/daemon/device-probe-service.js +47 -0
  132. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  133. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  134. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  135. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  136. package/dist/lib/daemon/runner.js +15 -2
  137. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  138. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  139. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  140. package/dist/lib/daemon/self-heal-service.js +48 -0
  141. package/dist/lib/daemon/service.d.ts +102 -0
  142. package/dist/lib/daemon/service.js +98 -0
  143. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  144. package/dist/lib/daemon/session-index-service.js +40 -0
  145. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  146. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  147. package/dist/lib/daemon/supervisor.d.ts +87 -0
  148. package/dist/lib/daemon/supervisor.js +298 -0
  149. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  150. package/dist/lib/daemon/watchdog-service.js +40 -0
  151. package/dist/lib/daemon-health.d.ts +15 -0
  152. package/dist/lib/daemon-health.js +29 -5
  153. package/dist/lib/daemon-services.d.ts +8 -1
  154. package/dist/lib/daemon-services.js +55 -0
  155. package/dist/lib/daemon-ticks.d.ts +13 -2
  156. package/dist/lib/daemon-ticks.js +22 -20
  157. package/dist/lib/device-config.d.ts +25 -10
  158. package/dist/lib/device-config.js +90 -39
  159. package/dist/lib/devices/config-migration.d.ts +7 -0
  160. package/dist/lib/devices/config-migration.js +67 -3
  161. package/dist/lib/devices/health.d.ts +27 -2
  162. package/dist/lib/devices/health.js +39 -9
  163. package/dist/lib/devices/interactive-host.d.ts +15 -0
  164. package/dist/lib/devices/interactive-host.js +53 -0
  165. package/dist/lib/devices/registry.d.ts +61 -4
  166. package/dist/lib/devices/registry.js +94 -38
  167. package/dist/lib/devices/stats-cache.d.ts +26 -0
  168. package/dist/lib/devices/stats-cache.js +59 -7
  169. package/dist/lib/devices/worker-pick.d.ts +48 -0
  170. package/dist/lib/devices/worker-pick.js +113 -0
  171. package/dist/lib/exec.d.ts +70 -14
  172. package/dist/lib/exec.js +110 -38
  173. package/dist/lib/feed/answer.d.ts +29 -0
  174. package/dist/lib/feed/answer.js +145 -0
  175. package/dist/lib/feed/attention.d.ts +83 -0
  176. package/dist/lib/feed/attention.js +0 -0
  177. package/dist/lib/feed/feed.d.ts +109 -0
  178. package/dist/lib/feed/feed.js +154 -3
  179. package/dist/lib/feed/pr-status.d.ts +14 -0
  180. package/dist/lib/feed/pr-status.js +47 -0
  181. package/dist/lib/feed/watch.d.ts +59 -0
  182. package/dist/lib/feed/watch.js +180 -0
  183. package/dist/lib/fleet/capture.js +6 -0
  184. package/dist/lib/fleet/remote-login.js +8 -3
  185. package/dist/lib/fleet/types.d.ts +17 -0
  186. package/dist/lib/harness/adapter.d.ts +11 -0
  187. package/dist/lib/harness/adapters/claude.js +38 -24
  188. package/dist/lib/harness/adapters/codex.js +2 -4
  189. package/dist/lib/harness/adapters/cursor.js +8 -11
  190. package/dist/lib/helper-download.d.ts +108 -0
  191. package/dist/lib/helper-download.js +194 -0
  192. package/dist/lib/helper-versions.d.ts +52 -0
  193. package/dist/lib/helper-versions.js +58 -0
  194. package/dist/lib/hooks/cache.js +19 -0
  195. package/dist/lib/hooks/install.js +2 -2
  196. package/dist/lib/hooks/match.d.ts +3 -0
  197. package/dist/lib/hooks/match.js +25 -0
  198. package/dist/lib/hosts/credentials.d.ts +1 -1
  199. package/dist/lib/hosts/credentials.js +1 -1
  200. package/dist/lib/hosts/dispatch.d.ts +7 -1
  201. package/dist/lib/hosts/dispatch.js +24 -5
  202. package/dist/lib/hosts/passthrough.d.ts +8 -15
  203. package/dist/lib/hosts/passthrough.js +35 -16
  204. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  205. package/dist/lib/hosts/providers/devices.js +1 -1
  206. package/dist/lib/hosts/reconnect.d.ts +179 -21
  207. package/dist/lib/hosts/reconnect.js +266 -45
  208. package/dist/lib/hosts/registry.js +7 -0
  209. package/dist/lib/installations/migrate.d.ts +4 -5
  210. package/dist/lib/installations/migrate.js +6 -7
  211. package/dist/lib/installations/shims.d.ts +1 -1
  212. package/dist/lib/installations/shims.js +23 -13
  213. package/dist/lib/installations/versions.js +25 -2
  214. package/dist/lib/linear-autoclose.d.ts +1 -1
  215. package/dist/lib/linear-autoclose.js +1 -1
  216. package/dist/lib/loop.d.ts +2 -2
  217. package/dist/lib/loop.js +1 -1
  218. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  219. package/dist/lib/menubar/download-menubar.js +60 -0
  220. package/dist/lib/menubar/install-menubar.d.ts +44 -7
  221. package/dist/lib/menubar/install-menubar.js +105 -23
  222. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  223. package/dist/lib/menubar/notify-desktop.js +6 -6
  224. package/dist/lib/model-tiers.d.ts +1 -1
  225. package/dist/lib/open-url.d.ts +60 -2
  226. package/dist/lib/open-url.js +167 -12
  227. package/dist/lib/project-probe.d.ts +1 -1
  228. package/dist/lib/project-probe.js +1 -1
  229. package/dist/lib/project-pull.d.ts +1 -1
  230. package/dist/lib/project-pull.js +2 -2
  231. package/dist/lib/projects.d.ts +1 -1
  232. package/dist/lib/projects.js +1 -1
  233. package/dist/lib/refresh-coordinator.d.ts +1 -1
  234. package/dist/lib/refresh-coordinator.js +1 -1
  235. package/dist/lib/sandbox.js +6 -7
  236. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  237. package/dist/lib/secrets/download-keychain.js +70 -0
  238. package/dist/lib/secrets/install-helper.d.ts +20 -1
  239. package/dist/lib/secrets/install-helper.js +44 -3
  240. package/dist/lib/session/active.d.ts +67 -0
  241. package/dist/lib/session/active.js +86 -2
  242. package/dist/lib/session/db.d.ts +11 -1
  243. package/dist/lib/session/db.js +142 -8
  244. package/dist/lib/session/discover.d.ts +23 -1
  245. package/dist/lib/session/discover.js +44 -10
  246. package/dist/lib/session/highlights.d.ts +49 -1
  247. package/dist/lib/session/highlights.js +95 -0
  248. package/dist/lib/session/host-link.d.ts +16 -2
  249. package/dist/lib/session/host-link.js +7 -1
  250. package/dist/lib/session/parse.js +33 -1
  251. package/dist/lib/session/prompt.d.ts +35 -0
  252. package/dist/lib/session/prompt.js +56 -0
  253. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  254. package/dist/lib/session/remote/remote-list.js +19 -2
  255. package/dist/lib/session/remote/watch.d.ts +1 -1
  256. package/dist/lib/session/remote/watch.js +17 -3
  257. package/dist/lib/session/render.js +7 -3
  258. package/dist/lib/session/session-cache.d.ts +53 -0
  259. package/dist/lib/session/session-cache.js +93 -1
  260. package/dist/lib/session/state.js +1 -1
  261. package/dist/lib/session/tool-calls.d.ts +2 -0
  262. package/dist/lib/session/tool-calls.js +1 -1
  263. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  264. package/dist/lib/session/trajectory-compare.js +116 -0
  265. package/dist/lib/session/trajectory-html.d.ts +19 -0
  266. package/dist/lib/session/trajectory-html.js +770 -0
  267. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  268. package/dist/lib/session/trajectory-lineage.js +195 -0
  269. package/dist/lib/session/trajectory-text.d.ts +37 -0
  270. package/dist/lib/session/trajectory-text.js +284 -0
  271. package/dist/lib/session/trajectory.d.ts +102 -0
  272. package/dist/lib/session/trajectory.js +384 -0
  273. package/dist/lib/session/types.d.ts +15 -0
  274. package/dist/lib/sha256-asset.d.ts +26 -0
  275. package/dist/lib/sha256-asset.js +41 -0
  276. package/dist/lib/share/backend.d.ts +95 -0
  277. package/dist/lib/share/backend.js +139 -0
  278. package/dist/lib/share/delete.d.ts +6 -2
  279. package/dist/lib/share/delete.js +8 -9
  280. package/dist/lib/share/html.d.ts +22 -0
  281. package/dist/lib/share/html.js +88 -0
  282. package/dist/lib/share/provision.d.ts +18 -4
  283. package/dist/lib/share/provision.js +38 -4
  284. package/dist/lib/share/publish.d.ts +39 -15
  285. package/dist/lib/share/publish.js +55 -26
  286. package/dist/lib/share/worker-template.js +171 -11
  287. package/dist/lib/sqlite.js +16 -3
  288. package/dist/lib/ssh-exec.d.ts +65 -0
  289. package/dist/lib/ssh-exec.js +120 -4
  290. package/dist/lib/startup/command-registry.d.ts +3 -1
  291. package/dist/lib/startup/command-registry.js +6 -3
  292. package/dist/lib/startup/dev-build.js +3 -3
  293. package/dist/lib/state.d.ts +2 -2
  294. package/dist/lib/state.js +11 -5
  295. package/dist/lib/subagents-registry.js +1 -1
  296. package/dist/lib/teams/agents.js +9 -0
  297. package/dist/lib/terminal/index.d.ts +1 -1
  298. package/dist/lib/terminal/inject.d.ts +19 -0
  299. package/dist/lib/terminal/inject.js +19 -9
  300. package/dist/lib/terminal/types.d.ts +1 -1
  301. package/dist/lib/terminal/types.js +1 -1
  302. package/dist/lib/tmux/orphan-reap.js +1 -1
  303. package/dist/lib/tmux/session.d.ts +46 -13
  304. package/dist/lib/tmux/session.js +204 -27
  305. package/dist/lib/traces/backend.d.ts +29 -0
  306. package/dist/lib/traces/backend.js +46 -0
  307. package/dist/lib/traces/classify.d.ts +52 -0
  308. package/dist/lib/traces/classify.js +72 -0
  309. package/dist/lib/traces/config.d.ts +10 -0
  310. package/dist/lib/traces/config.js +2 -0
  311. package/dist/lib/traces/provision.d.ts +19 -0
  312. package/dist/lib/traces/provision.js +30 -0
  313. package/dist/lib/traces/sync.d.ts +150 -0
  314. package/dist/lib/traces/sync.js +447 -0
  315. package/dist/lib/traces/worker-template.d.ts +4 -0
  316. package/dist/lib/traces/worker-template.js +159 -0
  317. package/dist/lib/types.d.ts +34 -19
  318. package/dist/lib/types.js +14 -0
  319. package/dist/lib/usage-backoff.d.ts +2 -1
  320. package/dist/lib/usage-backoff.js +60 -19
  321. package/dist/lib/usage-refresh.d.ts +18 -4
  322. package/dist/lib/usage-refresh.js +68 -19
  323. package/dist/lib/watchdog/log.d.ts +1 -1
  324. package/dist/lib/watchdog/log.js +1 -1
  325. package/dist/lib/watchdog/runner.d.ts +36 -45
  326. package/dist/lib/watchdog/runner.js +150 -119
  327. package/dist/lib/watchdog/service.d.ts +0 -1
  328. package/dist/lib/watchdog/service.js +0 -1
  329. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  330. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  331. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  332. package/dist/lib/watchdog/watchdog.js +37 -104
  333. package/dist/lib/workflows.d.ts +1 -1
  334. package/package.json +5 -7
  335. package/dist/commands/usage.d.ts +0 -27
  336. package/dist/commands/usage.js +0 -96
  337. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  338. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  339. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  340. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  341. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  342. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  343. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
  344. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  345. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
  346. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  347. package/dist/lib/usage-fleet.d.ts +0 -32
  348. package/dist/lib/usage-fleet.js +0 -125
@@ -0,0 +1,145 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { getActiveSessions } from '../session/active.js';
3
+ import { resolveAnswerRoute, resumeArgv } from '../answer-router.js';
4
+ import { enqueue, mailboxDir } from '../mailbox.js';
5
+ import { injectIntoTerminal } from '../terminal/index.js';
6
+ import { verifyOperatorIdentity } from '../operator.js';
7
+ import { blockIdForSession, getAnswerRecord, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
8
+ import { reconcileAttention } from './attention.js';
9
+ import { readPullRequestStatus } from './pr-status.js';
10
+ import { getAgentsInvocation } from '../daemon/daemon.js';
11
+ function sessionForBlock(block, sessions) {
12
+ return sessions.find((session) => session.sessionId === block.sessionId || session.agentId === block.mailboxId);
13
+ }
14
+ function blockFromAttention(attention, session) {
15
+ return {
16
+ blockId: blockIdForSession(attention.sessionId), sessionId: attention.sessionId,
17
+ mailboxId: attention.mailboxId, host: attention.host, runtime: session.kind,
18
+ generation: attention.key.slice(attention.key.lastIndexOf('/') + 1), source: attention.source,
19
+ state: 'open', sourceCursor: attention.sourceCursor, project: attention.project,
20
+ ts: attention.openedAt, questions: [attention.question ?? { text: 'Continue from this attention item.' }],
21
+ kind: attention.kind === 'permission' ? 'notification' : attention.kind === 'declared' ? 'declared' : attention.source === 'system' ? 'control' : 'question',
22
+ safeDefault: attention.safeDefault,
23
+ };
24
+ }
25
+ async function resolveBlock(attentionKey, sessions, root) {
26
+ const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
27
+ for (const session of sessions) {
28
+ if (!session.sessionId)
29
+ continue;
30
+ let block = readBlock(blockIdForSession(session.sessionId), root);
31
+ const projectedSession = { ...session, host: ownerHost };
32
+ const attention = reconcileAttention({ block, session: projectedSession, pullRequest: await readPullRequestStatus(session), resolution: readResolution(blockIdForSession(session.sessionId), root), nowMs: Date.now() });
33
+ if (attention?.key === attentionKey && block)
34
+ return { block, attention };
35
+ // The winning caller advances the block to answered before a concurrent
36
+ // loser resolves it. Reconstruct only this block's original generation so
37
+ // the loser can return already_answered without routing a second reply.
38
+ if (block) {
39
+ const original = reconcileAttention({ block: { ...block, state: 'open', answer: undefined }, session: projectedSession, nowMs: Date.now() });
40
+ if (original?.key === attentionKey && getAnswerRecord(block.blockId, root))
41
+ return { block, attention: original };
42
+ }
43
+ if (attention?.key === attentionKey) {
44
+ block = blockFromAttention(attention, session);
45
+ publishBlock(block, root);
46
+ return { block, attention };
47
+ }
48
+ }
49
+ throw new Error(`No open attention item matches '${attentionKey}'.`);
50
+ }
51
+ /** Atomically claim the first answer, then route it over the session's recorded reply rail. */
52
+ export async function claimAndRouteAttentionAnswer(input) {
53
+ if ((input.choiceId == null) === (input.text == null))
54
+ throw new Error('Exactly one of choiceId or text is required.');
55
+ const sessions = input.sessions ?? await getActiveSessions();
56
+ const { block, attention } = await resolveBlock(input.attentionKey, sessions, input.feedRoot);
57
+ const choice = input.choiceId == null ? undefined : attention.choices?.find((item) => item.id === input.choiceId);
58
+ if (input.choiceId != null && !choice)
59
+ throw new Error(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`);
60
+ const answer = input.text ?? choice?.deliveryKey ?? choice?.label;
61
+ if (!answer)
62
+ throw new Error('Answer is empty.');
63
+ const verified = input.operator.verified && verifyOperatorIdentity(input.operator.id);
64
+ const previousResolution = readResolution(block.blockId, input.feedRoot);
65
+ const claim = recordAnswer(block.blockId, {
66
+ answeredBy: input.operator.label,
67
+ answeredFrom: 'feed',
68
+ operatorId: input.operator.id,
69
+ verified,
70
+ }, input.feedRoot);
71
+ if (!claim.ok) {
72
+ if ('unauthorized' in claim)
73
+ throw new Error(claim.reason);
74
+ const existing = getAnswerRecord(block.blockId, input.feedRoot) ?? claim.existing;
75
+ return { status: 'already_answered', receipt: {
76
+ msgId: `answer-${block.blockId}`,
77
+ status: 'queued',
78
+ at: existing.answeredAt,
79
+ from: existing.answeredBy ?? existing.answeredFrom,
80
+ } };
81
+ }
82
+ const claimed = getAnswerRecord(block.blockId, input.feedRoot);
83
+ if (!claimed)
84
+ throw new Error(`Answer claim for '${input.attentionKey}' was not persisted.`);
85
+ const session = sessionForBlock(block, sessions);
86
+ const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
87
+ if (route.kind === 'refuse') {
88
+ rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
89
+ throw new Error(route.reason);
90
+ }
91
+ let msgId;
92
+ try {
93
+ if (route.kind === 'mailbox') {
94
+ msgId = enqueue(mailboxDir(block.mailboxId, input.mailboxRoot), {
95
+ to: block.mailboxId, text: answer, from: input.operator.label, blockId: block.blockId,
96
+ });
97
+ }
98
+ else if (route.kind === 'resume') {
99
+ const invocation = getAgentsInvocation(resumeArgv(route));
100
+ const child = spawn(invocation.command, invocation.args, { stdio: 'inherit', env: process.env });
101
+ const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
102
+ if (code !== 0)
103
+ throw new Error(`Resume delivery for '${input.attentionKey}' exited ${code}.`);
104
+ msgId = `resume-${Date.now()}`;
105
+ }
106
+ else {
107
+ if (!route.inject || route.payload == null)
108
+ throw new Error(`Incomplete ${route.kind} reply rail.`);
109
+ const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: true, combined: false });
110
+ if (!delivered.ok)
111
+ throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
112
+ msgId = `inject-${Date.now()}`;
113
+ }
114
+ }
115
+ catch (error) {
116
+ rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
117
+ throw error;
118
+ }
119
+ const receipt = { msgId, status: 'queued', at: new Date().toISOString(), from: input.operator.label };
120
+ recordMessageReceipt(block.blockId, receipt, input.feedRoot);
121
+ return { status: 'delivered', receipt };
122
+ }
123
+ /** Forward a fleet attention answer to the device that owns its scope. */
124
+ export async function forwardFeedAnswer(input) {
125
+ const remoteArgs = ['feed', 'answer', input.attentionKey, '--json'];
126
+ if (input.choiceId != null)
127
+ remoteArgs.push('--choice', input.choiceId);
128
+ if (input.text != null)
129
+ remoteArgs.push('--text', input.text);
130
+ if (input.operatorId)
131
+ remoteArgs.push('--as', input.operatorId);
132
+ const invocation = getAgentsInvocation(['ssh', input.host, 'agents', ...remoteArgs]);
133
+ const child = spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
134
+ let stdout = '';
135
+ let stderr = '';
136
+ child.stdout.on('data', (chunk) => { stdout += String(chunk); });
137
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
138
+ const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
139
+ if (code !== 0)
140
+ throw new Error(stderr.trim() || `Remote feed answer on '${input.host}' exited ${code}.`);
141
+ const line = stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
142
+ if (!line)
143
+ throw new Error(`Remote feed answer on '${input.host}' returned no JSON receipt.`);
144
+ return JSON.parse(line);
145
+ }
@@ -0,0 +1,83 @@
1
+ import type { ActiveSession } from '../session/active.js';
2
+ import { type AttentionResolution, type AttentionSource, type AttentionState, type BlockOption, type BlockQuestion, type OpenBlock, type SourceCursor } from './feed.js';
3
+ /** What an attention item is asking of the operator. */
4
+ export type AttentionKind = 'question' | 'permission' | 'plan_review' | 'declared' | 'failure' | 'stall' | 'review';
5
+ /** How an answer can be routed back to the waiting agent. */
6
+ export type ReplyCapability = 'terminal' | 'tmux' | 'cloud' | 'team' | 'none';
7
+ /**
8
+ * One answerable choice on an attention item. Extends {@link BlockOption} with a
9
+ * stable `id` the UI echoes back when the operator picks it, and an optional
10
+ * `deliveryKey` (the harness-native keystroke / selection token) that Track B's
11
+ * answer router resolves the reply rail with.
12
+ */
13
+ export interface AttentionChoice extends BlockOption {
14
+ id: string;
15
+ deliveryKey?: string;
16
+ }
17
+ /**
18
+ * The canonical operator-facing attention record. One reconciled thing that needs
19
+ * a human, whatever produced it. This is the public envelope Tracks B and C
20
+ * consume — a stability contract, not an internal shape.
21
+ */
22
+ export interface AttentionItem {
23
+ /** Stable identity: `host/session/generation`. A new generation is a new item. */
24
+ key: string;
25
+ sessionId: string;
26
+ mailboxId: string;
27
+ host: string;
28
+ project?: string;
29
+ kind: AttentionKind;
30
+ source: AttentionSource;
31
+ state: AttentionState;
32
+ /** ISO-8601 timestamp the ask opened. */
33
+ openedAt: string;
34
+ question?: BlockQuestion;
35
+ choices?: AttentionChoice[];
36
+ replyCapability: ReplyCapability;
37
+ safeDefault?: string;
38
+ /**
39
+ * Clusters IDENTICAL asks across different agents for batch triage. It is a hash
40
+ * of the RAW question intent (kind + verbatim question text + option labels) —
41
+ * deliberately NOT the UI-normalized display text, so two agents asking the same
42
+ * thing collapse even when the floor renders them differently.
43
+ */
44
+ fingerprint: string;
45
+ /** Where in the source this generation sits — the fence a resolution compares against. */
46
+ sourceCursor?: SourceCursor;
47
+ }
48
+ /**
49
+ * A pull-request attention signal, supplied BY the CLI (Track B refreshes it on a
50
+ * bounded TTL and hands it in). Track A only consumes the computed `needsHuman`
51
+ * flag — it never reaches into the extension's PR board or recomputes review
52
+ * state. Fields mirror the `gh pr view` projection so Track B can populate it.
53
+ */
54
+ export interface PullRequestAttentionSignal {
55
+ number: number;
56
+ title?: string;
57
+ url?: string;
58
+ /** True when the PR is in a state that needs a human decision (review / merge). */
59
+ needsHuman: boolean;
60
+ reviewDecision?: string;
61
+ mergeable?: string;
62
+ state?: string;
63
+ isDraft?: boolean;
64
+ }
65
+ /**
66
+ * Fingerprint of an ask's raw intent. Uses the verbatim question text and option
67
+ * labels (NOT display-normalized text) so identical asks from different agents
68
+ * share a fingerprint for batch triage.
69
+ */
70
+ export declare function attentionFingerprint(kind: AttentionKind, question?: BlockQuestion): string;
71
+ /**
72
+ * Reconcile the feed block ledger, the session lifecycle, a CLI-supplied PR
73
+ * signal, and the latest resolution tombstone into one attention item — or
74
+ * `undefined` when nothing needs a human. Pure; the extension never chooses
75
+ * authority.
76
+ */
77
+ export declare function reconcileAttention(input: {
78
+ block?: OpenBlock;
79
+ session: ActiveSession;
80
+ pullRequest?: PullRequestAttentionSignal;
81
+ resolution?: AttentionResolution;
82
+ nowMs: number;
83
+ }): AttentionItem | undefined;
Binary file
@@ -30,12 +30,54 @@ export interface AnswerRecord {
30
30
  /** Whether the operator identity was verified against the registry. */
31
31
  verified?: boolean;
32
32
  }
33
+ /**
34
+ * Where an attention record came from, strongest evidence first.
35
+ * hook — a harness event (AskUserQuestion / permission / notification).
36
+ * declared — the agent said it is stuck (`agents feed post --blocked`).
37
+ * lifecycle — a structural session signal (plan handoff / permission wait).
38
+ * heuristic — an inferred prose question that decays.
39
+ * system — a synthetic card the feed computed (runaway / needy / PR review).
40
+ */
41
+ export type AttentionSource = 'hook' | 'declared' | 'lifecycle' | 'heuristic' | 'system';
42
+ /**
43
+ * The lifecycle state of an attention record. A block is `open` until it is
44
+ * answered from some surface, `answered` once a surface claimed it, `consumed`
45
+ * once the agent read the reply, `continued` once the agent moved on, and
46
+ * `resolved` for a terminal clear (the block file is being removed). This is the
47
+ * axis the operator projection ranks on — only `open` needs a human.
48
+ */
49
+ export type AttentionState = 'open' | 'answered' | 'consumed' | 'continued' | 'resolved';
50
+ /**
51
+ * A cursor into the source that produced a block — the transcript's last write
52
+ * (`lastActivityMs`) or a specific transcript `eventId`. It is what lets a
53
+ * resolution tombstone say "this generation was resolved at THIS point" so a
54
+ * stale lifecycle re-read cannot resurrect it while the session sits still, yet
55
+ * a genuinely newer turn (a strictly later cursor) is allowed through as a new
56
+ * generation. See {@link AttentionResolution} and `reconcileAttention`.
57
+ */
58
+ export interface SourceCursor {
59
+ lastActivityMs?: number;
60
+ eventId?: string;
61
+ }
33
62
  export interface OpenBlock {
34
63
  blockId: string;
35
64
  sessionId: string;
36
65
  mailboxId: string;
37
66
  host: string;
38
67
  runtime: string;
68
+ /**
69
+ * Generation key for this block's CURRENT question. A new AskUserQuestion (or a
70
+ * new `--blocked` post) in the same session mints a new generation, so a
71
+ * resolution tombstone for the previous generation cannot suppress the fresh
72
+ * ask. Derived from `ts` when a writer did not stamp it — see {@link blockGeneration}.
73
+ */
74
+ generation?: string;
75
+ /** How this block came to exist. Derived from {@link kind} when absent — see {@link blockSource}. */
76
+ source?: AttentionSource;
77
+ /** Lifecycle state. Derived from the answer/continue markers when absent — see {@link deriveBlockState}. */
78
+ state?: AttentionState;
79
+ /** Where in the source this block's generation sits — carried onto its resolution tombstone. */
80
+ sourceCursor?: SourceCursor;
39
81
  /** Indexed launch origin, added at read time when the live session is known. */
40
82
  origin?: 'cli' | 'routine';
41
83
  /** Routine definition name when origin is `routine`. */
@@ -121,6 +163,66 @@ export interface FeedAskStats {
121
163
  totalAskCount: number;
122
164
  recentAskTimestamps: string[];
123
165
  }
166
+ /**
167
+ * Why an attention generation stopped needing a human — a resolution tombstone.
168
+ * answered — a surface recorded an answer.
169
+ * continued — the agent consumed the answer and moved on.
170
+ * tool_completed — the tool an approval gated finished (permission cleared).
171
+ * expired — a decaying heuristic ask aged out.
172
+ * session_advanced— the transcript moved past the block (Stop/PostToolUse clear).
173
+ */
174
+ export type ResolutionReason = 'answered' | 'continued' | 'tool_completed' | 'expired' | 'session_advanced';
175
+ /**
176
+ * A resolution tombstone. It is recorded BEFORE the open-block view is cleared so
177
+ * a resolved generation can never silently resurrect from a stale lifecycle
178
+ * re-read (the RUSH-1522 stale-flag class): the reconciler suppresses a
179
+ * lifecycle candidate whose generation the tombstone already covers, until the
180
+ * session advances strictly past `sourceCursor`. One tombstone per block id,
181
+ * latest-wins — the block id is stable per session, so this never grows unbounded.
182
+ */
183
+ export interface AttentionResolution {
184
+ blockId: string;
185
+ /** The generation this tombstone resolved — matched against a fresh candidate's generation. */
186
+ generation: string;
187
+ /** ISO-8601 timestamp of the resolution. */
188
+ resolvedAt: string;
189
+ /** Where in the source the resolved generation sat; the reconciler compares a candidate's cursor against it. */
190
+ sourceCursor?: SourceCursor;
191
+ reason: ResolutionReason;
192
+ }
193
+ /**
194
+ * Canonical generation for a block. A writer that stamped `generation` wins;
195
+ * otherwise the publish timestamp `ts` is the generation, because the feed
196
+ * rewrites `ts` only when a NEW question replaces the old one (an answer/continue
197
+ * update to the same block keeps `ts`). So `ts` changes exactly when the ask
198
+ * changes — which is what a generation must track.
199
+ */
200
+ export declare function blockGeneration(block: OpenBlock): string;
201
+ /**
202
+ * Canonical source for a block. A writer that stamped `source` wins; otherwise it
203
+ * is derived from `kind`: a declared block is `declared`, a synthetic control card
204
+ * is `system`, everything else (question / notification, written by a harness hook)
205
+ * is `hook`.
206
+ */
207
+ export declare function blockSource(block: OpenBlock): AttentionSource;
208
+ /**
209
+ * Canonical lifecycle state for a block. A writer that stamped `state` wins;
210
+ * otherwise it is derived from the markers already on the block: `continuedAt`
211
+ * means `continued`, a recorded `answer` means `answered`, and anything else is
212
+ * still `open`. This is the ONE place that turns the historical marker fields into
213
+ * the lifecycle axis, so no consumer re-derives it and drifts.
214
+ */
215
+ export declare function deriveBlockState(block: OpenBlock): AttentionState;
216
+ /**
217
+ * Record a resolution tombstone (latest-wins per block id). Called from the
218
+ * answer / continue / clear paths BEFORE the open-block view is removed, so the
219
+ * reconciler always has the tombstone by the time the block file is gone.
220
+ */
221
+ export declare function recordResolution(resolution: AttentionResolution, root?: string): void;
222
+ /** Read the latest resolution tombstone for a block, if one exists. */
223
+ export declare function readResolution(blockId: string, root?: string): AttentionResolution | undefined;
224
+ /** Read every resolution tombstone. Returns them sorted by stable block filename. */
225
+ export declare function listResolutions(root?: string): AttentionResolution[];
124
226
  /**
125
227
  * Stable block id for a session. One block per session -- a new question
126
228
  * replaces the previous one (the agent can only ask one question at a time).
@@ -155,6 +257,13 @@ export declare function recordAnswer(blockId: string, answer: {
155
257
  }, root?: string): RecordAnswerResult;
156
258
  /** Read the answer record for a block, if one exists. */
157
259
  export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
260
+ /**
261
+ * Release one specific answer claim after its reply rail failed. The compare on
262
+ * `answeredAt` makes this a conditional rollback: it can never erase a newer
263
+ * claimant. The caller supplies the exact pre-claim block/resolution snapshots,
264
+ * restoring the attention lifecycle to the state another surface observed.
265
+ */
266
+ export declare function rollbackAnswerClaim(blockId: string, answeredAt: string, previousBlock: OpenBlock, previousResolution: AttentionResolution | undefined, root?: string): boolean;
158
267
  /** True when the block has already been answered. */
159
268
  export declare function isBlockAnswered(blockId: string, root?: string): boolean;
160
269
  /**
@@ -29,6 +29,80 @@ import { getFeedDir, getUserAgentsDir } from '../state.js';
29
29
  import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
30
30
  import { projectKeyFromCwd } from '../project-key.js';
31
31
  import { atomicWriteJsonSync } from '../fs-atomic.js';
32
+ function resolutionDir(root) { return path.join(root, 'resolutions'); }
33
+ /**
34
+ * Canonical generation for a block. A writer that stamped `generation` wins;
35
+ * otherwise the publish timestamp `ts` is the generation, because the feed
36
+ * rewrites `ts` only when a NEW question replaces the old one (an answer/continue
37
+ * update to the same block keeps `ts`). So `ts` changes exactly when the ask
38
+ * changes — which is what a generation must track.
39
+ */
40
+ export function blockGeneration(block) {
41
+ return block.generation ?? block.ts;
42
+ }
43
+ /**
44
+ * Canonical source for a block. A writer that stamped `source` wins; otherwise it
45
+ * is derived from `kind`: a declared block is `declared`, a synthetic control card
46
+ * is `system`, everything else (question / notification, written by a harness hook)
47
+ * is `hook`.
48
+ */
49
+ export function blockSource(block) {
50
+ if (block.source)
51
+ return block.source;
52
+ switch (block.kind) {
53
+ case 'declared': return 'declared';
54
+ case 'control': return 'system';
55
+ default: return 'hook';
56
+ }
57
+ }
58
+ /**
59
+ * Canonical lifecycle state for a block. A writer that stamped `state` wins;
60
+ * otherwise it is derived from the markers already on the block: `continuedAt`
61
+ * means `continued`, a recorded `answer` means `answered`, and anything else is
62
+ * still `open`. This is the ONE place that turns the historical marker fields into
63
+ * the lifecycle axis, so no consumer re-derives it and drifts.
64
+ */
65
+ export function deriveBlockState(block) {
66
+ if (block.state)
67
+ return block.state;
68
+ if (block.continuedAt)
69
+ return 'continued';
70
+ if (block.answer)
71
+ return 'answered';
72
+ return 'open';
73
+ }
74
+ /**
75
+ * Record a resolution tombstone (latest-wins per block id). Called from the
76
+ * answer / continue / clear paths BEFORE the open-block view is removed, so the
77
+ * reconciler always has the tombstone by the time the block file is gone.
78
+ */
79
+ export function recordResolution(resolution, root) {
80
+ const dir = resolutionDir(root ?? getFeedDir());
81
+ ensureDir(dir);
82
+ atomicWriteJsonSync(path.join(dir, `${resolution.blockId}.json`), resolution);
83
+ }
84
+ /** Read the latest resolution tombstone for a block, if one exists. */
85
+ export function readResolution(blockId, root) {
86
+ return safeReadJson(path.join(resolutionDir(root ?? getFeedDir()), `${blockId}.json`));
87
+ }
88
+ /** Read every resolution tombstone. Returns them sorted by stable block filename. */
89
+ export function listResolutions(root) {
90
+ const dir = resolutionDir(root ?? getFeedDir());
91
+ let names;
92
+ try {
93
+ names = fs.readdirSync(dir);
94
+ }
95
+ catch {
96
+ return [];
97
+ }
98
+ const out = [];
99
+ for (const name of names.filter(n => n.endsWith('.json')).sort()) {
100
+ const parsed = safeReadJson(path.join(dir, name));
101
+ if (parsed?.blockId && parsed.generation && parsed.reason)
102
+ out.push(parsed);
103
+ }
104
+ return out;
105
+ }
32
106
  /**
33
107
  * Stable block id for a session. One block per session -- a new question
34
108
  * replaces the previous one (the agent can only ask one question at a time).
@@ -131,9 +205,20 @@ export function recordAnswer(blockId, answer, root) {
131
205
  }
132
206
  throw err;
133
207
  }
134
- // Marker created successfully -- mirror the answer into the block file.
208
+ // Marker created successfully -- mirror the answer into the block file and
209
+ // advance the lifecycle to `answered`. The resolution tombstone is written
210
+ // first, so if a stale lifecycle re-read races the block-file update the
211
+ // reconciler already refuses to resurrect this generation.
135
212
  if (block) {
213
+ recordResolution({
214
+ blockId,
215
+ generation: blockGeneration(block),
216
+ resolvedAt: record.answeredAt,
217
+ sourceCursor: block.sourceCursor,
218
+ reason: 'answered',
219
+ }, dir);
136
220
  block.answer = record;
221
+ block.state = 'answered';
137
222
  publishBlock(block, dir);
138
223
  }
139
224
  return { ok: true };
@@ -142,6 +227,37 @@ export function recordAnswer(blockId, answer, root) {
142
227
  export function getAnswerRecord(blockId, root) {
143
228
  return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
144
229
  }
230
+ /**
231
+ * Release one specific answer claim after its reply rail failed. The compare on
232
+ * `answeredAt` makes this a conditional rollback: it can never erase a newer
233
+ * claimant. The caller supplies the exact pre-claim block/resolution snapshots,
234
+ * restoring the attention lifecycle to the state another surface observed.
235
+ */
236
+ export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previousResolution, root) {
237
+ const dir = root ?? getFeedDir();
238
+ const marker = path.join(answeredDir(dir), `${blockId}.json`);
239
+ const current = safeReadJson(marker);
240
+ if (!current || current.answeredAt !== answeredAt)
241
+ return false;
242
+ // Restore while the O_EXCL marker still excludes every other claimant. The
243
+ // marker is removed LAST; once another writer can win recordAnswer, this
244
+ // rollback has no state left to overwrite.
245
+ publishBlock(previousBlock, dir);
246
+ const resolutionFile = path.join(resolutionDir(dir), `${blockId}.json`);
247
+ if (previousResolution)
248
+ recordResolution(previousResolution, dir);
249
+ else {
250
+ try {
251
+ fs.unlinkSync(resolutionFile);
252
+ }
253
+ catch (error) {
254
+ if (error.code !== 'ENOENT')
255
+ throw error;
256
+ }
257
+ }
258
+ fs.unlinkSync(marker);
259
+ return true;
260
+ }
145
261
  /** True when the block has already been answered. */
146
262
  export function isBlockAnswered(blockId, root) {
147
263
  return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
@@ -190,7 +306,16 @@ export function recordContinued(blockId, root) {
190
306
  const block = readBlock(blockId, dir);
191
307
  if (!block)
192
308
  return;
193
- block.continuedAt = new Date().toISOString();
309
+ const now = new Date().toISOString();
310
+ block.continuedAt = now;
311
+ block.state = 'continued';
312
+ recordResolution({
313
+ blockId,
314
+ generation: blockGeneration(block),
315
+ resolvedAt: now,
316
+ sourceCursor: block.sourceCursor,
317
+ reason: 'continued',
318
+ }, dir);
194
319
  publishBlock(block, dir);
195
320
  }
196
321
  /** Mark a decision-class block as hard-parked (no safe default existed). */
@@ -260,13 +385,20 @@ export function buildDeclaredBlock(agent, input) {
260
385
  .filter(Boolean)
261
386
  .map((label) => ({ label }));
262
387
  const project = projectKeyFromCwd(agent.cwd);
388
+ const ts = input.ts ?? new Date().toISOString();
263
389
  return {
264
390
  blockId: blockIdForSession(agent.sessionId),
265
391
  sessionId: agent.sessionId,
266
392
  mailboxId: agent.mailboxId,
267
393
  host: agent.host,
268
394
  runtime: agent.runtime,
269
- ts: input.ts ?? new Date().toISOString(),
395
+ ts,
396
+ // A declared block is an explicit, agent-raised attention record: it opens the
397
+ // lifecycle here, sourced `declared`, with `ts` as its generation so a later
398
+ // `--blocked` in the same session mints a fresh generation past any tombstone.
399
+ generation: ts,
400
+ source: 'declared',
401
+ state: 'open',
270
402
  kind: 'declared',
271
403
  questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
272
404
  blockClass: input.safeDefault ? 'approval' : 'decision',
@@ -337,6 +469,25 @@ export function listAskStats(root) {
337
469
  /** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
338
470
  export function removeBlock(blockId, root) {
339
471
  const dir = root ?? getFeedDir();
472
+ // Record a resolution tombstone BEFORE the block file and answered marker are
473
+ // gone, so a stale lifecycle re-read of the same session cannot resurrect this
474
+ // cleared generation. The reason names how it closed: an answered marker means
475
+ // the operator answered, `continuedAt` means the agent moved on, otherwise the
476
+ // transcript advanced past it (a Stop/PostToolUse clear). The tombstone itself
477
+ // is deliberately NOT cleared — it must outlive the block to do its job.
478
+ const block = readBlock(blockId, dir);
479
+ if (block) {
480
+ const reason = isBlockAnswered(blockId, dir)
481
+ ? 'answered'
482
+ : block.continuedAt ? 'continued' : 'session_advanced';
483
+ recordResolution({
484
+ blockId,
485
+ generation: blockGeneration(block),
486
+ resolvedAt: new Date().toISOString(),
487
+ sourceCursor: block.sourceCursor,
488
+ reason,
489
+ }, dir);
490
+ }
340
491
  clearBlockLifecycle(blockId, dir);
341
492
  try {
342
493
  fs.unlinkSync(blockPath(dir, blockId));
@@ -0,0 +1,14 @@
1
+ import type { ActiveSession } from '../session/active.js';
2
+ import type { PullRequestAttentionSignal } from './attention.js';
3
+ export declare const PR_STATUS_TTL_MS = 45000;
4
+ export declare const PR_STATUS_FIELDS = "number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup";
5
+ export interface PullRequestStatus extends PullRequestAttentionSignal {
6
+ statusCheckRollup?: unknown[];
7
+ }
8
+ /** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
9
+ export declare function readPullRequestStatus(session: ActiveSession, options?: {
10
+ nowMs?: number;
11
+ ttlMs?: number;
12
+ }): Promise<PullRequestStatus | undefined>;
13
+ export declare function projectPullRequestBoard(sessions: ActiveSession[]): Promise<PullRequestStatus[]>;
14
+ export declare function resetPullRequestStatusCache(): void;
@@ -0,0 +1,47 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ export const PR_STATUS_TTL_MS = 45_000;
5
+ export const PR_STATUS_FIELDS = 'number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup';
6
+ const cache = new Map();
7
+ function needsHuman(value) {
8
+ if (value.state !== 'OPEN' || value.isDraft)
9
+ return false;
10
+ const checks = value.statusCheckRollup ?? [];
11
+ const checksSettled = checks.every((check) => {
12
+ const row = check;
13
+ return row.conclusion === 'SUCCESS' || row.conclusion === 'NEUTRAL' || row.status === 'COMPLETED';
14
+ });
15
+ return value.reviewDecision !== 'APPROVED' || (value.mergeable === 'MERGEABLE' && checksSettled);
16
+ }
17
+ /** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
18
+ export async function readPullRequestStatus(session, options = {}) {
19
+ const ref = session.pr?.url ?? session.pr?.number;
20
+ if (!ref || !session.cwd)
21
+ return undefined;
22
+ const key = `${session.cwd}\0${String(ref)}`;
23
+ const now = options.nowMs ?? Date.now();
24
+ const hit = cache.get(key);
25
+ if (hit && hit.expiresAt > now)
26
+ return hit.value;
27
+ try {
28
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS], {
29
+ cwd: session.cwd,
30
+ timeout: 15_000,
31
+ maxBuffer: 1024 * 1024,
32
+ });
33
+ const raw = JSON.parse(stdout);
34
+ const value = { ...raw, url: session.pr?.url, needsHuman: needsHuman(raw) };
35
+ cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS), value });
36
+ return value;
37
+ }
38
+ catch {
39
+ cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS) });
40
+ return undefined;
41
+ }
42
+ }
43
+ export async function projectPullRequestBoard(sessions) {
44
+ const rows = await Promise.all(sessions.map((session) => readPullRequestStatus(session)));
45
+ return rows.filter((row) => row !== undefined);
46
+ }
47
+ export function resetPullRequestStatusCache() { cache.clear(); }