@phnx-labs/agents-cli 1.22.115 → 1.22.117

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 (91) hide show
  1. package/CHANGELOG.md +161 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.d.ts +18 -0
  5. package/dist/commands/exec.js +263 -152
  6. package/dist/commands/feed.js +65 -14
  7. package/dist/commands/sessions-picker.d.ts +11 -0
  8. package/dist/commands/sessions-picker.js +88 -7
  9. package/dist/commands/sessions.d.ts +21 -2
  10. package/dist/commands/sessions.js +157 -3
  11. package/dist/commands/setup-secrets.d.ts +2 -2
  12. package/dist/commands/setup-term.d.ts +25 -0
  13. package/dist/commands/setup-term.js +71 -0
  14. package/dist/commands/setup.d.ts +1 -1
  15. package/dist/commands/setup.js +12 -4
  16. package/dist/commands/ssh.js +1 -69
  17. package/dist/lib/accounting/rotate.d.ts +63 -1
  18. package/dist/lib/accounting/rotate.js +56 -0
  19. package/dist/lib/accounts/add.js +2 -2
  20. package/dist/lib/accounts/slots.js +32 -2
  21. package/dist/lib/answer-router.d.ts +11 -2
  22. package/dist/lib/answer-router.js +26 -2
  23. package/dist/lib/auth-mint.d.ts +5 -4
  24. package/dist/lib/auth-mint.js +4 -3
  25. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  26. package/dist/lib/browser/service.d.ts +10 -0
  27. package/dist/lib/browser/service.js +208 -36
  28. package/dist/lib/browser/types.d.ts +18 -0
  29. package/dist/lib/config-keys.d.ts +1 -1
  30. package/dist/lib/config-keys.js +5 -0
  31. package/dist/lib/device-config.js +61 -0
  32. package/dist/lib/devices/doctor-findings.js +2 -6
  33. package/dist/lib/feed/answer.d.ts +153 -4
  34. package/dist/lib/feed/answer.js +716 -105
  35. package/dist/lib/feed/feed.d.ts +61 -1
  36. package/dist/lib/feed/feed.js +226 -14
  37. package/dist/lib/feed/hub-server.d.ts +58 -3
  38. package/dist/lib/feed/hub-server.js +306 -54
  39. package/dist/lib/feed/pr-status.d.ts +8 -0
  40. package/dist/lib/feed/pr-status.js +9 -1
  41. package/dist/lib/feed-outcome.d.ts +1 -1
  42. package/dist/lib/feed-outcome.js +9 -2
  43. package/dist/lib/feed-policy.js +9 -3
  44. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  45. package/dist/lib/fleet/auth-sync.js +2 -89
  46. package/dist/lib/harness-auth-capabilities.js +7 -2
  47. package/dist/lib/hosts/dispatch.d.ts +20 -1
  48. package/dist/lib/hosts/dispatch.js +52 -30
  49. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  50. package/dist/lib/hosts/remote-cmd.js +27 -2
  51. package/dist/lib/mailbox.d.ts +12 -0
  52. package/dist/lib/mailbox.js +16 -2
  53. package/dist/lib/menubar/snapshot.d.ts +51 -0
  54. package/dist/lib/menubar/snapshot.js +42 -3
  55. package/dist/lib/open-url.js +2 -2
  56. package/dist/lib/placement.d.ts +2 -0
  57. package/dist/lib/placement.js +5 -0
  58. package/dist/lib/projects.d.ts +23 -0
  59. package/dist/lib/projects.js +78 -0
  60. package/dist/lib/secrets-cli.d.ts +3 -3
  61. package/dist/lib/secrets-cli.js +1 -1
  62. package/dist/lib/session/active.d.ts +1 -0
  63. package/dist/lib/session/active.js +8 -0
  64. package/dist/lib/session/db.d.ts +67 -3
  65. package/dist/lib/session/db.js +381 -126
  66. package/dist/lib/session/prompt.d.ts +23 -7
  67. package/dist/lib/session/prompt.js +46 -8
  68. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  69. package/dist/lib/session/remote/remote-list.js +22 -6
  70. package/dist/lib/session/remote/watch.d.ts +12 -0
  71. package/dist/lib/session/remote/watch.js +9 -0
  72. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  73. package/dist/lib/session/remote-preview-cache.js +373 -0
  74. package/dist/lib/session/tail.d.ts +50 -0
  75. package/dist/lib/session/tail.js +219 -0
  76. package/dist/lib/setup-tool-install.js +2 -1
  77. package/dist/lib/setup-tool-status.d.ts +1 -1
  78. package/dist/lib/setup-tool-status.js +7 -1
  79. package/dist/lib/signin-badge.d.ts +19 -4
  80. package/dist/lib/signin-badge.js +29 -11
  81. package/dist/lib/term-driver.d.ts +24 -0
  82. package/dist/lib/term-driver.js +36 -0
  83. package/dist/lib/terminal/index.d.ts +1 -1
  84. package/dist/lib/terminal/index.js +1 -1
  85. package/dist/lib/terminal/inject.d.ts +38 -0
  86. package/dist/lib/terminal/inject.js +55 -9
  87. package/dist/lib/terminal/transport.d.ts +15 -5
  88. package/dist/lib/terminal/transport.js +61 -11
  89. package/package.json +1 -1
  90. package/dist/lib/fleet/remote-login.d.ts +0 -170
  91. package/dist/lib/fleet/remote-login.js +0 -568
@@ -1,16 +1,170 @@
1
+ /**
2
+ * Deliver one operator answer to one open attention item (PHNX-3999).
3
+ *
4
+ * The AGI Menu abandons a reply at 30s, so every path here is bounded and every
5
+ * outcome is reported from real evidence:
6
+ *
7
+ * - **Match before enrichment.** The requested key names its session, so the
8
+ * candidate set is that session alone and the optional `gh pr view` read
9
+ * runs only for a key whose generation is a PR review. Enriching every
10
+ * active session first spent one 15s-bounded `gh` call per session on a
11
+ * question that never needed one.
12
+ * - **Unknown stays unknown.** Three outcomes are distinct and never merged: a
13
+ * confirmed failure (nothing was sent, the claim is released, retry is safe),
14
+ * an unconfirmed delivery (something may have landed, the claim is KEPT), and
15
+ * a real receipt. A timeout is unconfirmed, never a failure.
16
+ * - **Receipts are read, never synthesized, and never over-read.** `queued`
17
+ * means a rail took the answer and nothing more; only the agent's own
18
+ * `consumed`/`continued` resolves the item; `dropped`/`expired` are failures.
19
+ * - **Replay is de-duplicable or refused.** A claim stranded by a kill is
20
+ * adopted only on the mailbox rail, where the queued message's block id makes
21
+ * re-delivery detectable, and only through a compare-and-swap on the claim so
22
+ * two retries cannot both adopt it. A keystroke or resume rail cannot be
23
+ * replayed safely, so it reports unknown and points at the session.
24
+ * - **Exact tokens across the hop.** A remote answer is quoted with the
25
+ * canonical `shellQuote` and run through the canonical bounded `sshExecAsync`,
26
+ * so newlines and shell metacharacters arrive byte-identical, and the returned
27
+ * receipt is verified to belong to the key that was asked about.
28
+ *
29
+ * - **A claim is not a resolution.** The claim is taken `pending`
30
+ * (`recordAnswer`'s two-phase option): it locks the item against a second
31
+ * surface but leaves the block `open` and writes no tombstone, so the card
32
+ * stays in the operator's feed. Only a real receipt calls
33
+ * `confirmAnswerResolution` and lets the item leave. A delivery that never
34
+ * confirms can no longer make the request silently disappear.
35
+ */
1
36
  import { spawn } from 'node:child_process';
2
37
  import { getActiveSessions } from '../session/active.js';
3
38
  import { resolveAnswerRoute, resumeArgv } from '../answer-router.js';
4
- import { enqueue, mailboxDir } from '../mailbox.js';
5
- import { injectIntoTerminal } from '../terminal/index.js';
39
+ import { enqueue, mailboxDir, readBox } from '../mailbox.js';
40
+ import { backendCarriesPaste, injectIntoTerminal } from '../terminal/index.js';
6
41
  import { verifyOperatorIdentity } from '../operator.js';
7
- import { blockIdForSession, getAnswerRecord, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
42
+ import { machineId, normalizeHost } from '../machine-id.js';
43
+ import { shellQuote, sshExecAsync, SSH_CONN_FAILURE_CODE, SSH_TIMEOUT_KILL_GRACE_MS } from '../ssh-exec.js';
44
+ import { blockGeneration, blockIdForSession, confirmAnswerResolution, getAnswerRecord, latestMessageReceipt, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
8
45
  import { reconcileAttention } from './attention.js';
9
46
  import { readPullRequestStatus } from './pr-status.js';
10
47
  import { getAgentsInvocation } from '../daemon/daemon.js';
11
- function sessionForBlock(block, sessions) {
12
- return sessions.find((session) => session.sessionId === block.sessionId || session.agentId === block.mailboxId);
48
+ /**
49
+ * Total budget for one answer. Sits below the AGI Menu's 30s abandon so the
50
+ * operator always gets a typed verdict instead of a timeout.
51
+ */
52
+ export const ANSWER_DEADLINE_MS = 20_000;
53
+ /** Slice of the budget the optional PR-review enrichment may spend. */
54
+ export const PR_ENRICHMENT_BUDGET_MS = 4_000;
55
+ /** Budget for the forwarded leg — the remote repeats the local work under its own deadline. */
56
+ export const REMOTE_ANSWER_TIMEOUT_MS = 25_000;
57
+ /**
58
+ * A claim older than this with no receipt was stranded by a kill, not left in
59
+ * flight: it exceeds a full local deadline plus the remote leg, so no live
60
+ * delivery can still be running behind it.
61
+ */
62
+ export const STRANDED_CLAIM_MS = 60_000;
63
+ /**
64
+ * How long to watch a `resume` child before giving up on a verdict. A resume
65
+ * runs the agent's whole next turn — minutes — so waiting for exit would blow the
66
+ * operator deadline on every headless answer. Only a clean early exit is booked;
67
+ * a non-zero exit or a still-running child is unknown, because neither proves
68
+ * the agent did or did not accept the prompt.
69
+ */
70
+ export const RESUME_SETTLE_MS = 2_000;
71
+ /**
72
+ * How long a caller that LOST the claim waits for the holder's receipt before
73
+ * reporting unknown. The holder is mid-delivery — a double-clicked answer is the
74
+ * common case — so a short wait turns "I can't tell" into the real receipt,
75
+ * without ever delivering a second copy.
76
+ */
77
+ export const HOLDER_RECEIPT_WAIT_MS = 400;
78
+ const HOLDER_RECEIPT_POLL_MS = 20;
79
+ /** A CONFIRMED failure: nothing reached a rail, the claim is released, retry is safe. */
80
+ export class AnswerError extends Error {
81
+ code;
82
+ constructor(message, code) {
83
+ super(message);
84
+ this.code = code;
85
+ this.name = 'AnswerError';
86
+ }
87
+ }
88
+ /**
89
+ * An UNCONFIRMED outcome: something may have been delivered. The claim is kept
90
+ * so a retry cannot double-send, and the operator is offered a delivery check
91
+ * rather than a resend.
92
+ */
93
+ export class AnswerUnknownError extends Error {
94
+ constructor(message) {
95
+ super(message);
96
+ this.name = 'AnswerUnknownError';
97
+ }
98
+ }
99
+ /**
100
+ * Split `<host>/<session>/<generation>` (attention.ts `attentionKey`). Fails
101
+ * loud on a malformed key: the old `slice(0, indexOf('/'))` returned a truncated
102
+ * host for a key with no separator, which then routed the answer at random.
103
+ */
104
+ export function parseAttentionKey(key) {
105
+ const first = key.indexOf('/');
106
+ const last = key.lastIndexOf('/');
107
+ if (first <= 0 || last <= first || last === key.length - 1) {
108
+ throw new AnswerError(`Malformed attention key '${key}' — expected '<host>/<session>/<generation>'.`, 'malformed_key');
109
+ }
110
+ return { host: key.slice(0, first), sessionId: key.slice(first + 1, last), generation: key.slice(last + 1) };
111
+ }
112
+ /**
113
+ * Whether THIS machine owns the item. The exact local/remote choice: the key's
114
+ * host is compared through the same {@link normalizeHost} the machine id is
115
+ * minted with, so `Yosemite-M4.local` and `yosemite-m4` are one machine.
116
+ */
117
+ export function answerOwnerIsLocal(host, self = machineId()) {
118
+ return normalizeHost(host) === normalizeHost(self);
119
+ }
120
+ /** A PR-review generation (`pr<number>:<decision>`) — the only kind a `gh` read can produce. */
121
+ function isPullRequestGeneration(generation) {
122
+ return /^pr\d+:/.test(generation);
123
+ }
124
+ // --- receipt semantics ------------------------------------------------------
125
+ /**
126
+ * What a stored receipt proves. The lifecycle vocabulary is fixed by
127
+ * `MessageReceipt`, and each member means exactly one thing here:
128
+ * queued — a rail took the answer. Delivery, never resolution.
129
+ * consumed/continued— the agent itself acknowledged it. This resolves the item.
130
+ * dropped/expired — terminal failure; it is the newest truth for its message
131
+ * (the write rank puts it top precisely so it cannot be
132
+ * regressed), which is exactly why it must not read as success.
133
+ */
134
+ export function classifyReceipt(receipt) {
135
+ if (receipt.status === 'consumed' || receipt.status === 'continued')
136
+ return { delivery: 'receipt', resolved: true };
137
+ if (receipt.status === 'queued')
138
+ return { delivery: 'receipt', resolved: false };
139
+ return { delivery: 'failed', resolved: false };
140
+ }
141
+ function startDeadline(totalMs) { return { endMs: Date.now() + totalMs }; }
142
+ function remainingMs(deadline) { return deadline.endMs - Date.now(); }
143
+ /**
144
+ * Bound one delivery await. Expiry is UNKNOWN, not failure: the rail's own child
145
+ * may still land the answer, so the claim stays and the operator is sent to a
146
+ * delivery check.
147
+ */
148
+ async function withinDeadline(work, deadline, what) {
149
+ const budget = remainingMs(deadline);
150
+ if (budget <= 0)
151
+ throw new AnswerUnknownError(`${what} had no budget left; delivery is unknown.`);
152
+ let timer;
153
+ try {
154
+ return await Promise.race([
155
+ work,
156
+ new Promise((_, reject) => {
157
+ timer = setTimeout(() => reject(new AnswerUnknownError(`${what} did not finish in ${budget}ms; delivery is unknown.`)), budget);
158
+ timer.unref?.();
159
+ }),
160
+ ]);
161
+ }
162
+ finally {
163
+ if (timer)
164
+ clearTimeout(timer);
165
+ }
13
166
  }
167
+ // --- resolution -------------------------------------------------------------
14
168
  function blockFromAttention(attention, session) {
15
169
  return {
16
170
  blockId: blockIdForSession(attention.sessionId), sessionId: attention.sessionId,
@@ -26,129 +180,586 @@ function blockFromAttention(attention, session) {
26
180
  safeDefault: attention.safeDefault,
27
181
  };
28
182
  }
29
- async function resolveBlock(attentionKey, sessions, root) {
30
- const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
31
- for (const session of sessions) {
32
- if (!session.sessionId)
33
- continue;
34
- let block = readBlock(blockIdForSession(session.sessionId), root);
35
- const projectedSession = { ...session, host: ownerHost };
36
- const attention = reconcileAttention({ block, session: projectedSession, pullRequest: await readPullRequestStatus(session), resolution: readResolution(blockIdForSession(session.sessionId), root), nowMs: Date.now() });
37
- if (attention?.key === attentionKey && attention.kind === 'unverified') {
183
+ /**
184
+ * Reconcile one session against the requested key. `pullRequest` is passed only
185
+ * on the enrichment pass — `reconcileAttention` consults it last
186
+ * (`attentionFromPullRequest`), so a block- or session-derived match never needed it.
187
+ */
188
+ function matchSession(session, key, attentionKey, root, pullRequest) {
189
+ const projected = { ...session, host: key.host };
190
+ const blockId = blockIdForSession(session.sessionId);
191
+ const block = readBlock(blockId, root);
192
+ const attention = reconcileAttention({
193
+ block, session: projected, pullRequest,
194
+ resolution: readResolution(blockId, root), nowMs: Date.now(),
195
+ });
196
+ if (attention?.key === attentionKey) {
197
+ if (attention.kind === 'unverified') {
38
198
  // No confirmed prompt to land a reply in: an answer typed into the session
39
199
  // could hit an empty prompt line or a different dialog (PHNX-3999).
40
- throw new Error(`'${attentionKey}' could not be verified as a pending request — open the session and answer it there.`);
200
+ throw new AnswerError(`'${attentionKey}' could not be verified as a pending request — open the session and answer it there.`, 'unverified');
41
201
  }
42
- if (attention?.key === attentionKey && block)
43
- return { block, attention };
44
- // The winning caller advances the block to answered before a concurrent
45
- // loser resolves it. Reconstruct only this block's original generation so
46
- // the loser can return already_answered without routing a second reply.
47
- if (block) {
48
- const original = reconcileAttention({ block: { ...block, state: 'open', answer: undefined }, session: projectedSession, nowMs: Date.now() });
49
- if (original?.key === attentionKey && getAnswerRecord(block.blockId, root))
50
- return { block, attention: original };
202
+ if (block)
203
+ return { hit: { block, attention, session } };
204
+ const reconstructed = blockFromAttention(attention, session);
205
+ publishBlock(reconstructed, root);
206
+ return { hit: { block: reconstructed, attention, session } };
207
+ }
208
+ // The winning caller advances the block to answered before a concurrent loser
209
+ // resolves it. Reconstruct only this block's original generation so the loser
210
+ // reaches the claim check and reports the real outcome, without routing again.
211
+ if (block && getAnswerRecord(blockId, root)) {
212
+ const original = reconcileAttention({
213
+ block: { ...block, state: 'open', answer: undefined }, session: projected, nowMs: Date.now(),
214
+ });
215
+ if (original?.key === attentionKey)
216
+ return { hit: { block, attention: original, session } };
217
+ }
218
+ return { observed: attention };
219
+ }
220
+ async function resolveTarget(key, attentionKey, sessions, deadline, root) {
221
+ // The key names its session, so only that session can produce it. Narrowing
222
+ // here is also what keeps an answer off an unrelated operator's pending item.
223
+ const candidates = sessions.filter((session) => session.sessionId && session.sessionId === key.sessionId);
224
+ if (candidates.length === 0) {
225
+ throw new AnswerError(`No live session '${key.sessionId}' here to answer '${attentionKey}'.`, 'no_session');
226
+ }
227
+ let observed;
228
+ for (const session of candidates) {
229
+ const outcome = matchSession(session, key, attentionKey, root, undefined);
230
+ if (outcome.hit)
231
+ return outcome.hit;
232
+ observed ??= outcome.observed;
233
+ }
234
+ // Enrichment pass — only a review key can come from a PR read, and only the
235
+ // named session is fetched, under whatever budget is left.
236
+ if (isPullRequestGeneration(key.generation)) {
237
+ for (const session of candidates) {
238
+ const budget = Math.min(PR_ENRICHMENT_BUDGET_MS, remainingMs(deadline));
239
+ if (budget <= 0)
240
+ break;
241
+ const pullRequest = await readPullRequestStatus({ ...session, host: key.host }, { timeoutMs: budget });
242
+ if (!pullRequest)
243
+ continue;
244
+ const outcome = matchSession(session, key, attentionKey, root, pullRequest);
245
+ if (outcome.hit)
246
+ return outcome.hit;
247
+ observed ??= outcome.observed;
51
248
  }
52
- if (attention?.key === attentionKey) {
53
- block = blockFromAttention(attention, session);
54
- publishBlock(block, root);
55
- return { block, attention };
249
+ }
250
+ // The session is live but has moved on — a different generation, or none. That
251
+ // is a STALE request, not a missing one: the operator answered yesterday's card.
252
+ throw new AnswerError(observed
253
+ ? `'${attentionKey}' is no longer the open request for '${key.sessionId}' — it is now '${observed.key}'.`
254
+ : `'${attentionKey}' is no longer open — session '${key.sessionId}' has no pending request.`, 'stale');
255
+ }
256
+ /**
257
+ * Take over a claim a kill stranded, atomically.
258
+ *
259
+ * `rollbackAnswerClaim` compares `answeredAt` before releasing, so it IS the
260
+ * compare-and-swap: exactly one racer can release this exact claim, and it
261
+ * re-takes it immediately under `recordAnswer`'s `O_EXCL`. A racer that loses
262
+ * either the release or the re-take finds a live marker and reports
263
+ * `already_answered` instead of delivering a second copy.
264
+ */
265
+ function adoptStrandedClaim(block, stranded, operator, verified, root) {
266
+ let released;
267
+ try {
268
+ released = rollbackAnswerClaim(block.blockId, stranded.answeredAt, { ...block, state: 'open', answer: undefined }, undefined, root);
269
+ }
270
+ catch {
271
+ // The release is a read-compare-then-unlink, so a racing adopter that got
272
+ // there first leaves this one unlinking a marker that is already gone. That
273
+ // is losing the race, not an error — fall through and let the loser report.
274
+ return undefined;
275
+ }
276
+ if (!released)
277
+ return undefined;
278
+ const retaken = recordAnswer(block.blockId, {
279
+ answeredBy: operator.label, answeredFrom: 'feed', operatorId: operator.id, verified,
280
+ }, root, { pending: true });
281
+ if (!retaken.ok)
282
+ return undefined;
283
+ return getAnswerRecord(block.blockId, root);
284
+ }
285
+ /**
286
+ * Wait briefly for the claim holder to record its receipt. Read-only — it polls
287
+ * the block's own receipt list and never claims, routes or resends.
288
+ */
289
+ async function awaitHolderReceipt(blockId, waitMs, origin, root) {
290
+ const until = Date.now() + waitMs;
291
+ for (;;) {
292
+ const receipt = latestMessageReceipt(blockId, root, origin);
293
+ if (receipt || Date.now() >= until)
294
+ return receipt;
295
+ await new Promise((resolve) => { const t = setTimeout(resolve, HOLDER_RECEIPT_POLL_MS); t.unref?.(); });
296
+ }
297
+ }
298
+ function heldClaimResult(block, attentionKey, host, existing, receipt) {
299
+ const base = { attentionKey, blockId: block.blockId, host, attempt: existing.answeredAt };
300
+ if (receipt) {
301
+ const { delivery, resolved } = classifyReceipt(receipt);
302
+ return {
303
+ status: delivery === 'failed' ? 'failed' : 'already_answered',
304
+ delivery, receipt, resolved,
305
+ reason: `Answered by ${existing.answeredBy ?? existing.answeredFrom} at ${existing.answeredAt}; the rail reports '${receipt.status}'.`,
306
+ ...(delivery === 'failed' ? { code: 'rail_failed' } : {}),
307
+ ...base,
308
+ };
309
+ }
310
+ return {
311
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
312
+ reason: `Claimed by ${existing.answeredBy ?? existing.answeredFrom} at ${existing.answeredAt}; no rail has reported a receipt. Check delivery rather than resending.`,
313
+ ...base,
314
+ };
315
+ }
316
+ /**
317
+ * Take the claim, or reconcile the one already on disk against the block's real
318
+ * receipts. A stranded claim is adopted only when `replayable` — the mailbox rail,
319
+ * where the queued message's block id makes a second enqueue detectable. A
320
+ * keystroke or resume rail may already have landed before the kill and cannot be
321
+ * de-duplicated, so it stays unknown.
322
+ */
323
+ async function claimOrReconcile(block, attentionKey, host, operator, verified, replayable, generation, nowMs, deadline, root) {
324
+ // PENDING: claimed, not resolved. The card stays in the operator's feed until
325
+ // a rail reports a receipt, so a claim whose delivery never lands cannot make
326
+ // the request silently disappear.
327
+ const claim = recordAnswer(block.blockId, {
328
+ answeredBy: operator.label, answeredFrom: 'feed', operatorId: operator.id, verified,
329
+ }, root, { pending: true });
330
+ if (claim.ok) {
331
+ const created = getAnswerRecord(block.blockId, root);
332
+ if (!created)
333
+ throw new AnswerError(`Answer claim for '${attentionKey}' was not persisted.`, 'rail_failed');
334
+ return { claim: created, adopted: false };
335
+ }
336
+ if ('unauthorized' in claim)
337
+ throw new AnswerError(claim.reason, 'unauthorized');
338
+ const existing = getAnswerRecord(block.blockId, root) ?? claim.existing;
339
+ // Scope every receipt read to THIS ask and THIS attempt, so a leftover receipt
340
+ // from an earlier question on the same session cannot answer for this one.
341
+ const origin = { generation, attempt: existing.answeredAt };
342
+ const claimedAtMs = Date.parse(existing.answeredAt);
343
+ const stranded = Number.isFinite(claimedAtMs) && nowMs - claimedAtMs >= STRANDED_CLAIM_MS;
344
+ // A fresh claim with no receipt is a delivery IN FLIGHT (the double-clicked
345
+ // answer), so give the holder a moment to record it rather than reporting a
346
+ // scary unknown for what is about to be a receipt.
347
+ const receipt = stranded
348
+ ? latestMessageReceipt(block.blockId, root, origin)
349
+ : await awaitHolderReceipt(block.blockId, Math.max(0, Math.min(HOLDER_RECEIPT_WAIT_MS, remainingMs(deadline))), origin, root);
350
+ if (receipt)
351
+ return { adopted: false, lost: heldClaimResult(block, attentionKey, host, existing, receipt) };
352
+ // No receipt and the claim predates any possible live delivery: a kill cut it
353
+ // between the claim and the receipt.
354
+ if (stranded && replayable) {
355
+ const adopted = adoptStrandedClaim(block, existing, operator, verified, root);
356
+ if (adopted)
357
+ return { claim: adopted, adopted: true };
358
+ }
359
+ return { adopted: false, lost: heldClaimResult(block, attentionKey, host, existing, undefined) };
360
+ }
361
+ /** A multiline free-text answer needs a rail that inserts rather than submits per line. */
362
+ function isMultilineFreeText(route, answer) {
363
+ return (route.payload ?? '') === answer && answer.includes('\n');
364
+ }
365
+ async function deliverMailbox(block, answer, operator, adopted, origin, mailboxRoot) {
366
+ const dir = mailboxDir(block.mailboxId, mailboxRoot);
367
+ // An adopted claim may already have enqueued before it was killed. The whole
368
+ // spool is scanned — inbox, processing AND consumed (`readBox`, not `peek`) —
369
+ // because a kill AFTER the agent drained the message would otherwise look
370
+ // like nothing was ever sent and enqueue the answer a second time.
371
+ //
372
+ // The match is on the ASK (block id + generation), not just the block id: a
373
+ // block id is per SESSION, so an already-consumed message answering question N
374
+ // would otherwise suppress a genuine delivery for question N+1. It is NOT keyed
375
+ // on the attempt: adopting a stranded claim necessarily mints a NEW attempt, so
376
+ // requiring an attempt match would never find the message the killed run queued
377
+ // and would duplicate the answer.
378
+ const existing = adopted
379
+ ? readBox(dir).find((msg) => msg.blockId === block.blockId && msg.generation === origin.generation)
380
+ : undefined;
381
+ const msgId = existing?.msgId ?? enqueue(dir, {
382
+ to: block.mailboxId, text: answer, from: operator.label, blockId: block.blockId,
383
+ generation: origin.generation, attempt: origin.attempt,
384
+ });
385
+ return {
386
+ receipt: { msgId, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin },
387
+ ...(existing ? { reason: 'Re-used the message a stranded claim had already enqueued.' } : {}),
388
+ };
389
+ }
390
+ /**
391
+ * Re-enter a headless agent with the answer as its next user turn.
392
+ *
393
+ * Nothing here can prove non-delivery. A non-zero exit does NOT mean the prompt
394
+ * never ran — the agent may have acted on it and then crashed — and a process
395
+ * still running is not evidence the prompt was accepted either. Both are
396
+ * therefore UNKNOWN, which keeps the claim and sends the operator to a delivery
397
+ * check instead of a resend. Only a clean exit is booked, as `queued`: the
398
+ * resume command completed, which is the rail taking the answer — not the agent
399
+ * acknowledging it, which is what `consumed`/`continued` mean.
400
+ */
401
+ async function deliverResume(route, block, claimedAt, operator, attentionKey, deadline, origin) {
402
+ const invocation = getAgentsInvocation(resumeArgv(route));
403
+ const settleMs = Math.max(0, Math.min(RESUME_SETTLE_MS, remainingMs(deadline)));
404
+ const child = spawn(invocation.command, invocation.args, {
405
+ detached: true, stdio: ['ignore', 'ignore', 'pipe'], env: process.env,
406
+ });
407
+ let stderr = '';
408
+ child.stderr?.setEncoding('utf-8');
409
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
410
+ const exit = await new Promise((resolve) => {
411
+ const timer = setTimeout(() => resolve(undefined), settleMs);
412
+ timer.unref?.();
413
+ child.once('error', () => { clearTimeout(timer); resolve(1); });
414
+ child.once('close', (code) => { clearTimeout(timer); resolve(code ?? 1); });
415
+ });
416
+ // Detach either way — the agent's turn outlives the operator's deadline.
417
+ child.stderr?.destroy();
418
+ child.unref();
419
+ if (exit !== undefined && exit !== 0) {
420
+ throw new AnswerUnknownError(`Resume for '${attentionKey}' exited ${exit}${stderr.trim() ? `: ${stderr.trim()}` : ''} — it may have taken the answer before failing.`);
421
+ }
422
+ if (exit === undefined) {
423
+ throw new AnswerUnknownError(`Resume for '${attentionKey}' is still running after ${settleMs}ms; it has the answer but has not acknowledged it.`);
424
+ }
425
+ return { receipt: { msgId: `resume-${block.blockId}-${claimedAt}`, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin } };
426
+ }
427
+ /**
428
+ * Drive a keystroke rail.
429
+ *
430
+ * A failure here is UNKNOWN, not a confirmed failure: the tmux/iTerm path writes
431
+ * the text and its Enter as two separate sends (terminal/inject.ts, the Ink-safe
432
+ * split), so a reported error can mean the text already landed in the composer
433
+ * and only the submit failed. Every check that can prove nothing was sent —
434
+ * rail completeness, paste capability — runs BEFORE the claim instead.
435
+ */
436
+ async function deliverInject(route, block, answer, claimedAt, operator, origin, deadline) {
437
+ // The rail gets the remaining budget, so it cancels its own process group and
438
+ // never starts a write the deadline can no longer cover.
439
+ const delivered = await injectIntoTerminal(route.inject, route.payload, {
440
+ enter: route.enter ?? true, combined: false, deadlineMs: Math.max(0, remainingMs(deadline)),
441
+ ...(isMultilineFreeText(route, answer) ? { paste: true } : {}),
442
+ });
443
+ if (!delivered.ok) {
444
+ // `writes === 0` means no write was even issued, so nothing landed and the
445
+ // item is cleanly retryable. Anything past the first write is ambiguous: the
446
+ // text may sit in the composer with only its submit missing.
447
+ // `started === 0` is the ONLY proof nothing reached the terminal. A spec that
448
+ // was started and then failed or timed out may have written bytes first, so
449
+ // `writes` (which counts COMPLETED specs) cannot license a retry.
450
+ if (delivered.started === 0) {
451
+ throw new AnswerError(`${delivered.error ?? `Failed to deliver over ${route.kind}`} — no keystroke was sent.`, 'rail_failed');
56
452
  }
453
+ throw new AnswerUnknownError(`${delivered.error ?? `Failed to deliver over ${route.kind}`} — ${delivered.started} of ${delivered.specs?.length ?? '?'} keystroke write(s) had already begun; open the session before resending.`);
57
454
  }
58
- throw new Error(`No open attention item matches '${attentionKey}'.`);
455
+ if (!delivered.confirmed) {
456
+ throw new AnswerUnknownError(`${delivered.backend} accepted the hand-off but cannot confirm the agent received it.`);
457
+ }
458
+ return { receipt: { msgId: `inject-${block.blockId}-${claimedAt}`, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin } };
459
+ }
460
+ /**
461
+ * Everything provable BEFORE a claim is taken. Each of these means nothing was
462
+ * sent, so raising here keeps the item cleanly retryable instead of parking it
463
+ * in an unknown state.
464
+ */
465
+ function preflightRoute(route, answer, attentionKey) {
466
+ if (route.kind === 'refuse')
467
+ throw new AnswerError(route.reason, 'refused');
468
+ if (route.kind === 'mailbox' || route.kind === 'resume')
469
+ return;
470
+ if (!route.inject || route.payload == null)
471
+ throw new AnswerError(`Incomplete ${route.kind} reply rail.`, 'refused');
472
+ if (isMultilineFreeText(route, answer) && !backendCarriesPaste(route.inject.backend)) {
473
+ throw new AnswerError(`A multiline answer cannot be typed into the ${route.inject.backend} rail for '${attentionKey}' — only tmux carries bracketed paste. Open the session and paste it there.`, 'refused');
474
+ }
475
+ }
476
+ // --- entry points -----------------------------------------------------------
477
+ /**
478
+ * Read-only reconciliation of an attention item's delivery state — what powers
479
+ * "Check delivery" on an unconfirmed answer. It NEVER claims, routes, adopts or
480
+ * resends; it reports the stored claim and the block's real receipt so an
481
+ * operator can tell "still unconfirmed" from "the agent has it".
482
+ */
483
+ export function checkAnswerDelivery(attentionKey, feedRoot, expectedAttempt) {
484
+ const key = parseAttentionKey(attentionKey);
485
+ const blockId = blockIdForSession(key.sessionId);
486
+ const base = { attentionKey, blockId, host: key.host };
487
+ const claim = getAnswerRecord(blockId, feedRoot);
488
+ // Scoped to the requested ask AND the attempt being checked, so a receipt left
489
+ // by a different question or a superseded attempt is never read as this one's.
490
+ const receipt = claim
491
+ ? latestMessageReceipt(blockId, feedRoot, {
492
+ generation: key.generation, attempt: expectedAttempt ?? claim.answeredAt,
493
+ })
494
+ : undefined;
495
+ // A block id is per SESSION, so its claim and receipts belong to whatever
496
+ // generation the session is on NOW. Checking an older card against them would
497
+ // report the NEXT question's receipt as this question's answer, which is the
498
+ // one way a read-only check can still lie.
499
+ const block = readBlock(blockId, feedRoot);
500
+ const resolution = readResolution(blockId, feedRoot);
501
+ const liveGeneration = block ? blockGeneration(block) : resolution?.generation;
502
+ if (liveGeneration !== undefined && liveGeneration !== key.generation) {
503
+ return {
504
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
505
+ reason: `'${attentionKey}' is not the generation this session is on ('${liveGeneration}'), so its claim and receipts describe a different request.`,
506
+ ...base,
507
+ };
508
+ }
509
+ // An attempt the caller did not ask about is a DIFFERENT delivery — say so
510
+ // rather than answering about someone else's.
511
+ if (expectedAttempt !== undefined && claim && claim.answeredAt !== expectedAttempt) {
512
+ return {
513
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
514
+ reason: `Attempt '${expectedAttempt}' was replaced by one at ${claim.answeredAt}.`,
515
+ ...base, attempt: claim.answeredAt,
516
+ };
517
+ }
518
+ if (receipt) {
519
+ const { delivery, resolved } = classifyReceipt(receipt);
520
+ return {
521
+ status: delivery === 'failed' ? 'failed' : 'already_answered',
522
+ delivery, receipt, resolved,
523
+ reason: `The rail reports '${receipt.status}' for ${receipt.msgId}.`,
524
+ ...(delivery === 'failed' ? { code: 'rail_failed' } : {}),
525
+ ...base, ...(claim ? { attempt: claim.answeredAt } : {}),
526
+ };
527
+ }
528
+ if (claim) {
529
+ return {
530
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
531
+ reason: `Claimed by ${claim.answeredBy ?? claim.answeredFrom} at ${claim.answeredAt}; no rail has reported a receipt.`,
532
+ ...base, attempt: claim.answeredAt,
533
+ };
534
+ }
535
+ // No claim at all is the one state that IS a confirmed non-delivery: nothing
536
+ // ever took the item, so the operator can safely answer it again.
537
+ return {
538
+ status: 'failed', delivery: 'failed', resolved: false, code: 'rail_failed',
539
+ reason: `No answer has been claimed for '${attentionKey}'.`,
540
+ ...base,
541
+ };
59
542
  }
60
543
  /** Atomically claim the first answer, then route it over the session's recorded reply rail. */
61
544
  export async function claimAndRouteAttentionAnswer(input) {
62
- if ((input.choiceId == null) === (input.text == null))
63
- throw new Error('Exactly one of choiceId or text is required.');
64
- const sessions = input.sessions ?? await getActiveSessions();
65
- const { block, attention } = await resolveBlock(input.attentionKey, sessions, input.feedRoot);
545
+ if ((input.choiceId == null) === (input.text == null)) {
546
+ throw new AnswerError('Exactly one of choiceId or text is required.', 'empty_answer');
547
+ }
548
+ const deadline = startDeadline(input.deadlineMs ?? ANSWER_DEADLINE_MS);
549
+ const key = parseAttentionKey(input.attentionKey);
550
+ const sessions = input.sessions ?? await withinDeadline(getActiveSessions(), deadline, 'Reading live sessions');
551
+ const { block, attention, session } = await resolveTarget(key, input.attentionKey, sessions, deadline, input.feedRoot);
66
552
  const choice = input.choiceId == null ? undefined : attention.choices?.find((item) => item.id === input.choiceId);
67
- if (input.choiceId != null && !choice)
68
- throw new Error(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`);
553
+ if (input.choiceId != null && !choice) {
554
+ throw new AnswerError(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`, 'unknown_choice');
555
+ }
69
556
  const answer = input.text ?? choice?.deliveryKey ?? choice?.label;
70
557
  if (!answer)
71
- throw new Error('Answer is empty.');
558
+ throw new AnswerError('Answer is empty.', 'empty_answer');
559
+ // The route is pure, so it is resolved BEFORE the claim: a refusal or an
560
+ // incapable rail then fails with nothing claimed and nothing delivered.
561
+ const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
562
+ preflightRoute(route, answer, input.attentionKey);
72
563
  const verified = input.operator.verified && verifyOperatorIdentity(input.operator.id);
73
564
  const previousResolution = readResolution(block.blockId, input.feedRoot);
74
- const claim = recordAnswer(block.blockId, {
75
- answeredBy: input.operator.label,
76
- answeredFrom: 'feed',
77
- operatorId: input.operator.id,
78
- verified,
79
- }, input.feedRoot);
80
- if (!claim.ok) {
81
- if ('unauthorized' in claim)
82
- throw new Error(claim.reason);
83
- const existing = getAnswerRecord(block.blockId, input.feedRoot) ?? claim.existing;
84
- return { status: 'already_answered', receipt: {
85
- msgId: `answer-${block.blockId}`,
86
- status: 'queued',
87
- at: existing.answeredAt,
88
- from: existing.answeredBy ?? existing.answeredFrom,
89
- } };
90
- }
91
- const claimed = getAnswerRecord(block.blockId, input.feedRoot);
92
- if (!claimed)
93
- throw new Error(`Answer claim for '${input.attentionKey}' was not persisted.`);
94
- const session = sessionForBlock(block, sessions);
95
- const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
96
- if (route.kind === 'refuse') {
97
- rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
98
- throw new Error(route.reason);
565
+ const outcome = await claimOrReconcile(block, input.attentionKey, key.host, input.operator, verified, route.kind === 'mailbox', key.generation, Date.now(), deadline, input.feedRoot);
566
+ if (outcome.lost)
567
+ return outcome.lost;
568
+ const claim = outcome.claim;
569
+ // Every receipt this delivery writes names the ask and the attempt it belongs
570
+ // to, so a late acknowledgement can never be read against a different question.
571
+ const origin = { generation: key.generation, attempt: claim.answeredAt };
572
+ const base = { attentionKey: input.attentionKey, blockId: block.blockId, host: key.host, attempt: claim.answeredAt };
573
+ // An adopted claim was created by the killed run, so releasing it means
574
+ // restoring the pre-claim OPEN state, not the tombstone that claim wrote.
575
+ const restoreBlock = outcome.adopted ? { ...block, state: 'open', answer: undefined } : block;
576
+ const restoreResolution = outcome.adopted ? undefined : previousResolution;
577
+ let delivered;
578
+ try {
579
+ delivered = await withinDeadline(route.kind === 'mailbox'
580
+ ? deliverMailbox(block, answer, input.operator, outcome.adopted, origin, input.mailboxRoot)
581
+ : route.kind === 'resume'
582
+ ? deliverResume(route, block, claim.answeredAt, input.operator, input.attentionKey, deadline, origin)
583
+ : deliverInject(route, block, answer, claim.answeredAt, input.operator, origin, deadline), deadline, `Delivering '${input.attentionKey}' over ${route.kind}`);
584
+ }
585
+ catch (error) {
586
+ // Only a CONFIRMED failure releases the claim. Past this point the code is
587
+ // inside a rail that may already have written bytes, so anything unexpected
588
+ // is UNKNOWN too — keeping the claim is what stops a retry double-sending
589
+ // behind a delivery that may have landed.
590
+ if (error instanceof AnswerError) {
591
+ rollbackAnswerClaim(block.blockId, claim.answeredAt, restoreBlock, restoreResolution, input.feedRoot);
592
+ throw error;
593
+ }
594
+ const reason = error instanceof Error ? error.message : String(error);
595
+ return { status: 'unknown', delivery: 'unconfirmed', resolved: false, reason, ...base };
99
596
  }
100
- let msgId;
597
+ const { delivery, resolved } = classifyReceipt(delivered.receipt);
101
598
  try {
102
- if (route.kind === 'mailbox') {
103
- msgId = enqueue(mailboxDir(block.mailboxId, input.mailboxRoot), {
104
- to: block.mailboxId, text: answer, from: input.operator.label, blockId: block.blockId,
599
+ recordMessageReceipt(block.blockId, delivered.receipt, input.feedRoot);
600
+ // Only the AGENT's own acknowledgement resolves the item. A `queued` receipt
601
+ // says a rail took the answer and nothing more, so the card stays up until
602
+ // the mailbox drain records `consumed` (which promotes it through
603
+ // `recordMessageReceipt`) or the transcript moves past the block. The
604
+ // generation + attempt binding stops a slow delivery from resolving the ask
605
+ // the agent has since moved on to.
606
+ if (resolved) {
607
+ confirmAnswerResolution(block.blockId, input.feedRoot, {
608
+ generation: key.generation, answeredAt: claim.answeredAt,
105
609
  });
106
610
  }
107
- else if (route.kind === 'resume') {
108
- const invocation = getAgentsInvocation(resumeArgv(route));
109
- const child = spawn(invocation.command, invocation.args, { stdio: 'inherit', env: process.env });
110
- const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
111
- if (code !== 0)
112
- throw new Error(`Resume delivery for '${input.attentionKey}' exited ${code}.`);
113
- msgId = `resume-${Date.now()}`;
114
- }
115
- else {
116
- if (!route.inject || route.payload == null)
117
- throw new Error(`Incomplete ${route.kind} reply rail.`);
118
- const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: route.enter ?? true, combined: false });
119
- if (!delivered.ok)
120
- throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
121
- msgId = `inject-${Date.now()}`;
122
- }
123
611
  }
124
612
  catch (error) {
125
- rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
126
- throw error;
613
+ // The answer IS on the rail; only the bookkeeping failed. Reporting a
614
+ // failure here would invite a resend of something already delivered.
615
+ return {
616
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
617
+ reason: `Delivered over ${route.kind}, but the receipt could not be recorded: ${error instanceof Error ? error.message : String(error)}.`,
618
+ ...base,
619
+ };
127
620
  }
128
- const receipt = { msgId, status: 'queued', at: new Date().toISOString(), from: input.operator.label };
129
- recordMessageReceipt(block.blockId, receipt, input.feedRoot);
130
- return { status: 'delivered', receipt };
621
+ return {
622
+ status: 'delivered', delivery, receipt: delivered.receipt, resolved,
623
+ ...(delivered.reason ? { reason: delivered.reason } : {}),
624
+ ...base,
625
+ };
131
626
  }
132
- /** Forward a fleet attention answer to the device that owns its scope. */
133
- export async function forwardFeedAnswer(input) {
134
- const remoteArgs = ['feed', 'answer', input.attentionKey, '--json'];
627
+ /**
628
+ * The remote `agents feed answer` argv for a forwarded answer. Exported so the
629
+ * exact tokens that cross the hop are asserted directly.
630
+ */
631
+ export function remoteAnswerArgv(input) {
632
+ const argv = ['agents', 'feed', 'answer', input.attentionKey, '--json'];
633
+ if (input.check)
634
+ argv.push('--check');
635
+ if (input.attempt != null)
636
+ argv.push('--attempt', input.attempt);
135
637
  if (input.choiceId != null)
136
- remoteArgs.push('--choice', input.choiceId);
638
+ argv.push('--choice', input.choiceId);
137
639
  if (input.text != null)
138
- remoteArgs.push('--text', input.text);
640
+ argv.push('--text', input.text);
139
641
  if (input.operatorId)
140
- remoteArgs.push('--as', input.operatorId);
141
- const invocation = getAgentsInvocation(['ssh', input.host, 'agents', ...remoteArgs]);
142
- const child = spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
143
- let stdout = '';
144
- let stderr = '';
145
- child.stdout.on('data', (chunk) => { stdout += String(chunk); });
146
- child.stderr.on('data', (chunk) => { stderr += String(chunk); });
147
- const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
148
- if (code !== 0)
149
- throw new Error(stderr.trim() || `Remote feed answer on '${input.host}' exited ${code}.`);
150
- const line = stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
151
- if (!line)
152
- throw new Error(`Remote feed answer on '${input.host}' returned no JSON receipt.`);
153
- return JSON.parse(line);
642
+ argv.push('--as', input.operatorId);
643
+ return argv;
644
+ }
645
+ /**
646
+ * Forward a fleet attention answer to the device that owns its scope.
647
+ *
648
+ * Every token is POSIX-quoted with the canonical {@link shellQuote} before the
649
+ * remote login shell parses it, so a multiline answer or one carrying `$`, `"`,
650
+ * `` ` ``, `;` or a newline arrives byte-identical. The transport is the
651
+ * canonical bounded {@link sshExecAsync} — it disables ControlMaster whenever a
652
+ * timeout is set, so the bound actually tears the remote command down instead of
653
+ * orphaning it behind a control socket.
654
+ *
655
+ * A timeout is reported UNKNOWN: the remote may well have delivered before the
656
+ * link was cut, so the operator is offered a delivery check, never an implicit
657
+ * resend. The returned receipt is verified to be about the key that was asked
658
+ * for, so a mismatched or truncated remote reply cannot be trusted as one.
659
+ */
660
+ export async function forwardFeedAnswer(input) {
661
+ const timeoutMs = input.timeoutMs ?? REMOTE_ANSWER_TIMEOUT_MS;
662
+ const remoteCmd = remoteAnswerArgv(input).map(shellQuote).join(' ');
663
+ const unknown = (reason) => ({
664
+ status: 'unknown', delivery: 'unconfirmed', resolved: false,
665
+ reason: `${reason} Check delivery rather than resending.`,
666
+ attentionKey: input.attentionKey, host: input.host,
667
+ });
668
+ // The caller enforces the bound, not the transport. `sshExecAsync` resolves on
669
+ // the child's `close`, which a remote peer still holding the pipe can delay
670
+ // past the kill it already issued — so the operator's verdict is raced against
671
+ // the deadline directly and returns on time regardless.
672
+ const settled = await Promise.race([
673
+ sshExecAsync(input.host, remoteCmd, { timeoutMs }).then((value) => ({ value })),
674
+ new Promise((resolve) => {
675
+ const timer = setTimeout(() => resolve({}), timeoutMs + SSH_TIMEOUT_KILL_GRACE_MS + 500);
676
+ timer.unref?.();
677
+ }),
678
+ ]);
679
+ const result = settled.value;
680
+ if (!result || result.timedOut) {
681
+ return unknown(`Answering on '${input.host}' did not finish in ${timeoutMs}ms — it may already have been delivered.`);
682
+ }
683
+ // Once ssh has been handed the command there is NO way to prove the remote did
684
+ // not run it. Exit 255 covers both "could not connect" and "connection dropped
685
+ // after the answer was delivered", and a dropped link can lose stdout, so even
686
+ // an echoed start marker is not proof of its own absence. Every post-dispatch
687
+ // remote outcome is therefore UNKNOWN — the operator gets "check delivery",
688
+ // never a retry that could double-send. (A locally-detectable fault, such as an
689
+ // invalid ssh target, throws from `sshExecAsync` before anything is dispatched.)
690
+ if (result.code === SSH_CONN_FAILURE_CODE) {
691
+ return unknown(`ssh to '${input.host}' failed (exit 255)${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}; whether the answer ran there is unknown.`);
692
+ }
693
+ const line = result.stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
694
+ if (!line) {
695
+ return unknown(`Remote answer on '${input.host}' returned no JSON receipt (exit ${result.code}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}).`);
696
+ }
697
+ let parsed;
698
+ try {
699
+ parsed = JSON.parse(line);
700
+ }
701
+ catch {
702
+ return unknown(`Remote answer on '${input.host}' returned unreadable JSON.`);
703
+ }
704
+ const shapeProblem = remoteResultProblem(parsed, input.attentionKey, input.attempt);
705
+ if (shapeProblem)
706
+ return unknown(`Remote answer on '${input.host}' ${shapeProblem}.`);
707
+ return { ...parsed, host: input.host };
708
+ }
709
+ const ANSWER_STATUSES = ['delivered', 'already_answered', 'unknown', 'failed'];
710
+ const ANSWER_DELIVERIES = ['receipt', 'unconfirmed', 'failed'];
711
+ const RECEIPT_STATUSES = ['queued', 'consumed', 'continued', 'dropped', 'expired'];
712
+ /**
713
+ * Validate a forwarded result as a whole, not just its key: an off-key, truncated
714
+ * or shape-invalid reply is not evidence about THIS request, and a caller that
715
+ * trusted one would report another item's outcome as this one's.
716
+ */
717
+ function remoteResultProblem(parsed, attentionKey, requestedAttempt) {
718
+ if (parsed?.attentionKey !== attentionKey) {
719
+ return `reported '${parsed?.attentionKey ?? 'no key'}', not '${attentionKey}'`;
720
+ }
721
+ if (!ANSWER_STATUSES.includes(parsed.status))
722
+ return `reported an unknown status '${parsed.status}'`;
723
+ if (!ANSWER_DELIVERIES.includes(parsed.delivery))
724
+ return `reported an unknown delivery '${parsed.delivery}'`;
725
+ if (typeof parsed.resolved !== 'boolean')
726
+ return 'omitted the resolved flag';
727
+ if (parsed.blockId !== undefined && parsed.blockId !== blockIdForSession(parseAttentionKey(attentionKey).sessionId)) {
728
+ return `reported block '${parsed.blockId}', which is not this key's block`;
729
+ }
730
+ // An answer about a DIFFERENT attempt than the one asked about is not evidence
731
+ // for this one, even though the key matches.
732
+ if (requestedAttempt !== undefined && parsed.attempt !== undefined && parsed.attempt !== requestedAttempt) {
733
+ return `reported attempt '${parsed.attempt}', not the requested '${requestedAttempt}'`;
734
+ }
735
+ if (parsed.delivery === 'receipt') {
736
+ if (!parsed.receipt?.msgId)
737
+ return 'claimed a receipt without one';
738
+ if (!RECEIPT_STATUSES.includes(parsed.receipt.status)) {
739
+ return `reported an unknown receipt status '${parsed.receipt.status}'`;
740
+ }
741
+ // A dead message is a failure, not a delivery.
742
+ if (parsed.receipt.status === 'dropped' || parsed.receipt.status === 'expired') {
743
+ return `reported delivery 'receipt' for a '${parsed.receipt.status}' message`;
744
+ }
745
+ // The receipt must be about the ASK that was requested.
746
+ const generation = parseAttentionKey(attentionKey).generation;
747
+ if (parsed.receipt.generation !== undefined && parsed.receipt.generation !== generation) {
748
+ return `returned a receipt for ask '${parsed.receipt.generation}', not '${generation}'`;
749
+ }
750
+ if (parsed.attempt !== undefined && parsed.receipt.attempt !== undefined
751
+ && parsed.receipt.attempt !== parsed.attempt) {
752
+ return `returned a receipt for attempt '${parsed.receipt.attempt}', not its own '${parsed.attempt}'`;
753
+ }
754
+ // `resolved` means the agent itself acknowledged — only consumed/continued
755
+ // can support it. A `queued` receipt claiming resolution is inconsistent.
756
+ const acknowledged = parsed.receipt.status === 'consumed' || parsed.receipt.status === 'continued';
757
+ if (parsed.resolved !== acknowledged) {
758
+ return `reported resolved=${parsed.resolved} for a '${parsed.receipt.status}' receipt`;
759
+ }
760
+ }
761
+ else if (parsed.resolved) {
762
+ return `reported resolved=true with delivery '${parsed.delivery}'`;
763
+ }
764
+ return undefined;
154
765
  }