@phnx-labs/agents-cli 1.22.46 → 1.22.47

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 (167) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/LICENSE +102 -182
  3. package/README.md +31 -18
  4. package/dist/cli/command-registry.d.ts +1 -1
  5. package/dist/cli/command-registry.js +4 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.js +7 -5
  8. package/dist/commands/browser-sessions-picker.js +9 -7
  9. package/dist/commands/browser.js +266 -15
  10. package/dist/commands/config.js +26 -4
  11. package/dist/commands/cost.d.ts +1 -1
  12. package/dist/commands/detach.d.ts +13 -0
  13. package/dist/commands/detach.js +11 -8
  14. package/dist/commands/exec.js +55 -38
  15. package/dist/commands/feed-watch.d.ts +2 -0
  16. package/dist/commands/feed-watch.js +28 -0
  17. package/dist/commands/feed.js +27 -0
  18. package/dist/commands/feedback.js +3 -19
  19. package/dist/commands/insights.d.ts +1 -1
  20. package/dist/commands/insights.js +2 -2
  21. package/dist/commands/lease.js +3 -3
  22. package/dist/commands/routines.js +1 -1
  23. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  24. package/dist/commands/sessions-picker-factory.js +1 -1
  25. package/dist/commands/sessions-stop.d.ts +24 -0
  26. package/dist/commands/sessions-stop.js +66 -0
  27. package/dist/commands/sessions-trace.d.ts +82 -0
  28. package/dist/commands/sessions-trace.js +387 -0
  29. package/dist/commands/sessions.js +4 -0
  30. package/dist/commands/ssh.d.ts +15 -0
  31. package/dist/commands/ssh.js +194 -16
  32. package/dist/commands/teams.js +34 -2
  33. package/dist/commands/view.d.ts +2 -0
  34. package/dist/commands/view.js +18 -31
  35. package/dist/commands/watchdog.js +9 -14
  36. package/dist/lib/account-capabilities.d.ts +4 -0
  37. package/dist/lib/account-capabilities.js +13 -9
  38. package/dist/lib/account-registry.d.ts +2 -0
  39. package/dist/lib/account-registry.js +30 -2
  40. package/dist/lib/accounting/rotate.d.ts +9 -0
  41. package/dist/lib/accounting/rotate.js +12 -2
  42. package/dist/lib/accounting/usage.d.ts +70 -3
  43. package/dist/lib/accounting/usage.js +235 -63
  44. package/dist/lib/agent-modes.js +29 -1
  45. package/dist/lib/agent-spec/agents.d.ts +4 -3
  46. package/dist/lib/agent-spec/agents.js +14 -6
  47. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  48. package/dist/lib/analytics/mix-commands.js +2 -2
  49. package/dist/lib/auth-health.d.ts +13 -3
  50. package/dist/lib/auth-health.js +50 -8
  51. package/dist/lib/browser/devices.d.ts +19 -0
  52. package/dist/lib/browser/devices.js +23 -0
  53. package/dist/lib/browser/ipc.d.ts +1 -1
  54. package/dist/lib/browser/ipc.js +33 -7
  55. package/dist/lib/browser/profiles.d.ts +106 -1
  56. package/dist/lib/browser/profiles.js +300 -17
  57. package/dist/lib/browser/remote-control.d.ts +26 -2
  58. package/dist/lib/browser/remote-control.js +33 -3
  59. package/dist/lib/browser/runtime-state.d.ts +9 -0
  60. package/dist/lib/browser/runtime-state.js +24 -8
  61. package/dist/lib/browser/service.d.ts +37 -0
  62. package/dist/lib/browser/service.js +78 -20
  63. package/dist/lib/browser/sessions-list.d.ts +1 -3
  64. package/dist/lib/browser/sessions-list.js +3 -16
  65. package/dist/lib/browser/types.d.ts +12 -1
  66. package/dist/lib/codex-policy.d.ts +11 -1
  67. package/dist/lib/codex-policy.js +20 -5
  68. package/dist/lib/config-keys.d.ts +4 -3
  69. package/dist/lib/config-keys.js +17 -7
  70. package/dist/lib/daemon/runner.js +15 -2
  71. package/dist/lib/daemon-ticks.js +2 -2
  72. package/dist/lib/device-config.d.ts +25 -8
  73. package/dist/lib/device-config.js +90 -17
  74. package/dist/lib/devices/config-migration.d.ts +7 -0
  75. package/dist/lib/devices/config-migration.js +67 -3
  76. package/dist/lib/devices/health.d.ts +27 -2
  77. package/dist/lib/devices/health.js +39 -9
  78. package/dist/lib/devices/interactive-host.d.ts +15 -0
  79. package/dist/lib/devices/interactive-host.js +53 -0
  80. package/dist/lib/devices/registry.d.ts +61 -4
  81. package/dist/lib/devices/registry.js +94 -38
  82. package/dist/lib/devices/stats-cache.d.ts +6 -0
  83. package/dist/lib/devices/stats-cache.js +10 -2
  84. package/dist/lib/exec.d.ts +5 -3
  85. package/dist/lib/exec.js +32 -16
  86. package/dist/lib/feed/answer.d.ts +29 -0
  87. package/dist/lib/feed/answer.js +145 -0
  88. package/dist/lib/feed/attention.d.ts +83 -0
  89. package/dist/lib/feed/attention.js +0 -0
  90. package/dist/lib/feed/feed.d.ts +109 -0
  91. package/dist/lib/feed/feed.js +154 -3
  92. package/dist/lib/feed/pr-status.d.ts +14 -0
  93. package/dist/lib/feed/pr-status.js +47 -0
  94. package/dist/lib/feed/watch.d.ts +59 -0
  95. package/dist/lib/feed/watch.js +180 -0
  96. package/dist/lib/fleet/capture.js +6 -0
  97. package/dist/lib/fleet/remote-login.js +8 -3
  98. package/dist/lib/fleet/types.d.ts +17 -0
  99. package/dist/lib/harness/adapter.d.ts +11 -0
  100. package/dist/lib/harness/adapters/claude.js +38 -24
  101. package/dist/lib/harness/adapters/codex.js +2 -4
  102. package/dist/lib/hooks/cache.js +8 -0
  103. package/dist/lib/hooks/match.d.ts +3 -0
  104. package/dist/lib/hooks/match.js +25 -0
  105. package/dist/lib/hosts/passthrough.js +16 -0
  106. package/dist/lib/hosts/registry.js +7 -0
  107. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  108. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  109. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  110. package/dist/lib/menubar/install-menubar.js +26 -2
  111. package/dist/lib/open-url.d.ts +60 -2
  112. package/dist/lib/open-url.js +167 -12
  113. package/dist/lib/refresh-coordinator.d.ts +1 -1
  114. package/dist/lib/refresh-coordinator.js +1 -1
  115. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  116. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  117. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  118. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  119. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  120. package/dist/lib/session/active.d.ts +67 -0
  121. package/dist/lib/session/active.js +69 -1
  122. package/dist/lib/session/db.js +4 -2
  123. package/dist/lib/session/discover.d.ts +2 -0
  124. package/dist/lib/session/discover.js +12 -6
  125. package/dist/lib/session/prompt.d.ts +35 -0
  126. package/dist/lib/session/prompt.js +56 -0
  127. package/dist/lib/session/remote/watch.d.ts +1 -1
  128. package/dist/lib/session/remote/watch.js +6 -1
  129. package/dist/lib/session/render.js +7 -3
  130. package/dist/lib/session/session-cache.js +5 -1
  131. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  132. package/dist/lib/session/trajectory-compare.js +116 -0
  133. package/dist/lib/session/trajectory-html.d.ts +15 -0
  134. package/dist/lib/session/trajectory-html.js +750 -0
  135. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  136. package/dist/lib/session/trajectory-lineage.js +195 -0
  137. package/dist/lib/session/trajectory-text.d.ts +37 -0
  138. package/dist/lib/session/trajectory-text.js +277 -0
  139. package/dist/lib/session/trajectory.d.ts +102 -0
  140. package/dist/lib/session/trajectory.js +335 -0
  141. package/dist/lib/startup/command-registry.d.ts +3 -1
  142. package/dist/lib/startup/command-registry.js +5 -2
  143. package/dist/lib/state.d.ts +1 -1
  144. package/dist/lib/state.js +4 -2
  145. package/dist/lib/teams/agents.js +9 -0
  146. package/dist/lib/terminal/inject.d.ts +19 -0
  147. package/dist/lib/terminal/inject.js +19 -9
  148. package/dist/lib/tmux/session.d.ts +46 -13
  149. package/dist/lib/tmux/session.js +173 -27
  150. package/dist/lib/types.d.ts +16 -0
  151. package/dist/lib/usage-backoff.d.ts +2 -1
  152. package/dist/lib/usage-backoff.js +60 -19
  153. package/dist/lib/usage-refresh.d.ts +17 -3
  154. package/dist/lib/usage-refresh.js +57 -11
  155. package/dist/lib/watchdog/log.d.ts +1 -1
  156. package/dist/lib/watchdog/log.js +1 -1
  157. package/dist/lib/watchdog/runner.d.ts +36 -45
  158. package/dist/lib/watchdog/runner.js +150 -119
  159. package/dist/lib/watchdog/service.d.ts +0 -1
  160. package/dist/lib/watchdog/service.js +0 -1
  161. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  162. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  163. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  164. package/dist/lib/watchdog/watchdog.js +37 -104
  165. package/package.json +2 -2
  166. package/dist/commands/usage.d.ts +0 -27
  167. package/dist/commands/usage.js +0 -96
@@ -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(); }
@@ -0,0 +1,59 @@
1
+ import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus } from '../session/watch.js';
2
+ import { type AttentionItem } from './attention.js';
3
+ import { type ActivityEvent } from './activity.js';
4
+ export declare const FEED_WATCH_VERSION: 1;
5
+ type Base = {
6
+ v: 1;
7
+ type: string;
8
+ streamId: string;
9
+ sequence: number;
10
+ scope: string;
11
+ };
12
+ export type FeedWatchEnvelope = Base & {
13
+ type: 'reset';
14
+ capturedAt: number;
15
+ agents: SessionWatchRow[];
16
+ attention: AttentionItem[];
17
+ } | Base & {
18
+ type: 'agent.upsert';
19
+ rowKey: string;
20
+ agent: SessionWatchRow;
21
+ } | Base & {
22
+ type: 'attention.upsert';
23
+ rowKey: string;
24
+ attention: AttentionItem;
25
+ } | Base & {
26
+ type: 'attention.remove';
27
+ rowKey: string;
28
+ } | Base & {
29
+ type: 'activity.append';
30
+ event: ActivityEvent;
31
+ } | Base & {
32
+ type: 'scope';
33
+ capturedAt: number;
34
+ status: SessionWatchScopeStatus;
35
+ reason?: string;
36
+ } | Base & {
37
+ type: 'heartbeat';
38
+ capturedAt: number;
39
+ };
40
+ type FeedWatchPayload = FeedWatchEnvelope extends infer Envelope ? Envelope extends FeedWatchEnvelope ? Omit<Envelope, 'v' | 'streamId' | 'sequence'> : never : never;
41
+ export declare class FeedWatchState {
42
+ readonly streamId: string;
43
+ private sequence;
44
+ constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
45
+ emit(event: FeedWatchPayload): FeedWatchEnvelope;
46
+ }
47
+ export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
48
+ export declare function watchLocalFeed(options: {
49
+ scope: string;
50
+ signal: AbortSignal;
51
+ emit: (event: FeedWatchEnvelope) => void;
52
+ activityPollMs?: number;
53
+ }): Promise<void>;
54
+ export declare function watchFleetFeed(options: {
55
+ signal: AbortSignal;
56
+ emit: (event: FeedWatchEnvelope) => void;
57
+ reconnectMs?: number;
58
+ }): Promise<void>;
59
+ export {};
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import { loadDevices, isDialableDevice } from '../devices/registry.js';
5
+ import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
6
+ import { machineId, normalizeHost } from '../machine-id.js';
7
+ import { SSH_OPTS, controlOpts, shellQuote } from '../ssh-exec.js';
8
+ import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
9
+ import { watchLocalSessions } from '../session/watch.js';
10
+ import { readBlock, readResolution, blockIdForSession } from './feed.js';
11
+ import { reconcileAttention } from './attention.js';
12
+ import { readRecentActivity } from './activity.js';
13
+ import { readPullRequestStatus } from './pr-status.js';
14
+ export const FEED_WATCH_VERSION = 1;
15
+ export class FeedWatchState {
16
+ streamId;
17
+ sequence = 0;
18
+ constructor(streamId = randomUUID()) { this.streamId = streamId; }
19
+ emit(event) {
20
+ return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
21
+ }
22
+ }
23
+ async function attentionFor(agent) {
24
+ if (!agent.sessionId)
25
+ return undefined;
26
+ const blockId = blockIdForSession(agent.sessionId);
27
+ // ActiveSession.host names the terminal app; the feed contract's host is the
28
+ // device scope. Normalize only the reconciler input so lifecycle/PR keys are
29
+ // routable across the fleet while the projected agent row stays compatible.
30
+ const session = { ...agent, host: agent.sourceDevice, viewingIn: undefined };
31
+ return reconcileAttention({
32
+ block: readBlock(blockId), session,
33
+ resolution: readResolution(blockId),
34
+ pullRequest: await readPullRequestStatus(session), nowMs: Date.now(),
35
+ });
36
+ }
37
+ export async function projectSessionEnvelope(event, state) {
38
+ if (event.type === 'reset') {
39
+ const attention = (await Promise.all(event.rows.map(attentionFor))).filter((item) => item !== undefined);
40
+ return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: event.rows, attention })];
41
+ }
42
+ if (event.type === 'upsert') {
43
+ const attention = await attentionFor(event.row);
44
+ return [
45
+ state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent: event.row }),
46
+ attention
47
+ ? state.emit({ type: 'attention.upsert', scope: event.scope, rowKey: event.rowKey, attention })
48
+ : state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
49
+ ];
50
+ }
51
+ if (event.type === 'remove')
52
+ return [state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey })];
53
+ if (event.type === 'scope')
54
+ return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
55
+ return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
56
+ }
57
+ export async function watchLocalFeed(options) {
58
+ const state = new FeedWatchState();
59
+ let activityCursor = Date.now();
60
+ const agents = new Map();
61
+ const attention = new Map();
62
+ let pending = Promise.resolve();
63
+ const reconcileRows = async () => {
64
+ for (const [rowKey, agent] of agents) {
65
+ const item = await attentionFor(agent);
66
+ const next = item ? JSON.stringify(item) : '';
67
+ if (attention.get(rowKey) === next)
68
+ continue;
69
+ attention.set(rowKey, next);
70
+ options.emit(item
71
+ ? state.emit({ type: 'attention.upsert', scope: options.scope, rowKey, attention: item })
72
+ : state.emit({ type: 'attention.remove', scope: options.scope, rowKey }));
73
+ }
74
+ };
75
+ const activityTimer = setInterval(() => {
76
+ pending = pending.then(async () => {
77
+ const events = readRecentActivity({ sinceMs: activityCursor + 1 }).reverse();
78
+ for (const event of events) {
79
+ activityCursor = Math.max(activityCursor, Date.parse(event.ts));
80
+ options.emit(state.emit({ type: 'activity.append', scope: options.scope, event }));
81
+ }
82
+ await reconcileRows();
83
+ });
84
+ }, options.activityPollMs ?? 500);
85
+ const stopActivity = () => clearInterval(activityTimer);
86
+ options.signal.addEventListener('abort', stopActivity, { once: true });
87
+ try {
88
+ await watchLocalSessions({ scope: options.scope, signal: options.signal, emit: (event) => {
89
+ if (event.type === 'reset') {
90
+ agents.clear();
91
+ for (const row of event.rows)
92
+ agents.set(row.rowKey, row);
93
+ }
94
+ else if (event.type === 'upsert')
95
+ agents.set(event.rowKey, event.row);
96
+ else if (event.type === 'remove') {
97
+ agents.delete(event.rowKey);
98
+ attention.delete(event.rowKey);
99
+ }
100
+ pending = pending.then(() => projectSessionEnvelope(event, state)).then((events) => {
101
+ for (const projected of events) {
102
+ if (projected.type === 'reset') {
103
+ attention.clear();
104
+ for (const row of projected.agents)
105
+ attention.set(row.rowKey, '');
106
+ for (const item of projected.attention) {
107
+ const row = projected.agents.find((agent) => agent.sessionId === item.sessionId);
108
+ if (row)
109
+ attention.set(row.rowKey, JSON.stringify(item));
110
+ }
111
+ }
112
+ else if (projected.type === 'attention.upsert')
113
+ attention.set(projected.rowKey, JSON.stringify(projected.attention));
114
+ else if (projected.type === 'attention.remove')
115
+ attention.set(projected.rowKey, '');
116
+ options.emit(projected);
117
+ }
118
+ });
119
+ } });
120
+ await pending;
121
+ }
122
+ finally {
123
+ stopActivity();
124
+ }
125
+ }
126
+ function remoteFeedWatchCommand(os) {
127
+ const args = ['feed', 'watch', '--json', '--local'];
128
+ return remoteShellFor(os) === 'powershell'
129
+ ? buildWindowsAgentsCommand({ args })
130
+ : `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
131
+ }
132
+ export async function watchFleetFeed(options) {
133
+ const coordinator = new FeedWatchState();
134
+ const forward = (event) => {
135
+ const { v: _v, streamId: peerStreamId, sequence: peerSequence, ...payload } = event;
136
+ options.emit(coordinator.emit({ ...payload, peerStreamId, peerSequence }));
137
+ };
138
+ const local = watchLocalFeed({ scope: machineId(), signal: options.signal, emit: forward });
139
+ let devices;
140
+ try {
141
+ devices = await loadDevices();
142
+ }
143
+ catch {
144
+ await local;
145
+ return;
146
+ }
147
+ const self = machineId();
148
+ const peers = Object.values(devices).filter((device) => isDialableDevice(device) && normalizeHost(device.name) !== self && ['windows', 'linux', 'macos'].includes(device.platform));
149
+ const tasks = peers.map(async (device) => {
150
+ const scope = normalizeHost(device.name);
151
+ while (!options.signal.aborted) {
152
+ let target;
153
+ try {
154
+ target = sshTargetFor(device);
155
+ }
156
+ catch (error) {
157
+ options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: String(error) }));
158
+ break;
159
+ }
160
+ const child = spawn('ssh', [...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteFeedWatchCommand(device.platform)], { stdio: ['ignore', 'pipe', 'ignore'] });
161
+ const stop = () => child.kill('SIGTERM');
162
+ options.signal.addEventListener('abort', stop, { once: true });
163
+ const reader = createInterface({ input: child.stdout });
164
+ reader.on('line', (line) => { try {
165
+ const event = JSON.parse(line);
166
+ if (event.v === 1)
167
+ forward(event);
168
+ }
169
+ catch { /* protocol only */ } });
170
+ const code = await new Promise((resolve) => { child.once('error', () => resolve(null)); child.once('close', resolve); });
171
+ reader.close();
172
+ options.signal.removeEventListener('abort', stop);
173
+ if (options.signal.aborted)
174
+ break;
175
+ options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: code == null ? 'ssh failed' : `ssh exited ${code}` }));
176
+ await new Promise((resolve) => { const timer = setTimeout(resolve, options.reconnectMs ?? 2_000); options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
177
+ }
178
+ });
179
+ await Promise.all([local, ...tasks]);
180
+ }
@@ -58,6 +58,12 @@ export function captureFleet(prev, inputs) {
58
58
  if (prev?.discovery && Object.keys(prev.discovery).length > 0) {
59
59
  manifest.discovery = { ...prev.discovery };
60
60
  }
61
+ // Dismissals are operator state, not live state — a capture must never wipe
62
+ // them (fleet.ignored syncs; losing it re-suggests every dismissed node
63
+ // fleet-wide). Carry forward verbatim, same contract as `discovery`.
64
+ if (prev?.ignored && prev.ignored.length > 0) {
65
+ manifest.ignored = prev.ignored.map((e) => ({ ...e }));
66
+ }
61
67
  const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
62
68
  if (bundles && bundles.length > 0)
63
69
  manifest.secrets = { bundles: [...bundles].sort() };