@songsid/agend 2.1.7-beta.5 → 2.1.7-beta.7

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 (63) hide show
  1. package/dist/backend/codex.d.ts +34 -9
  2. package/dist/backend/codex.js +197 -33
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/grok.js +51 -0
  5. package/dist/backend/grok.js.map +1 -1
  6. package/dist/channel/adapters/discord.d.ts +11 -0
  7. package/dist/channel/adapters/discord.js +16 -2
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/types.d.ts +2 -0
  10. package/dist/cli.js +79 -132
  11. package/dist/cli.js.map +1 -1
  12. package/dist/completion-install.d.ts +32 -0
  13. package/dist/completion-install.js +125 -1
  14. package/dist/completion-install.js.map +1 -1
  15. package/dist/config-validator.js +45 -0
  16. package/dist/config-validator.js.map +1 -1
  17. package/dist/daemon.d.ts +4 -1
  18. package/dist/daemon.js +23 -6
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/delivery-outbox.d.ts +109 -0
  21. package/dist/delivery-outbox.js +306 -10
  22. package/dist/delivery-outbox.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +67 -7
  24. package/dist/fleet-manager.js +215 -59
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +10 -0
  27. package/dist/instance-config-impact.js +5 -0
  28. package/dist/instance-config-impact.js.map +1 -1
  29. package/dist/instance-lifecycle.d.ts +10 -2
  30. package/dist/instance-lifecycle.js +50 -32
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/instructions.d.ts +17 -0
  33. package/dist/instructions.js +24 -1
  34. package/dist/instructions.js.map +1 -1
  35. package/dist/locale.js +2 -0
  36. package/dist/locale.js.map +1 -1
  37. package/dist/ls-rows.d.ts +50 -0
  38. package/dist/ls-rows.js +152 -0
  39. package/dist/ls-rows.js.map +1 -0
  40. package/dist/outbound-handlers.d.ts +1 -1
  41. package/dist/outbound-handlers.js +6 -7
  42. package/dist/outbound-handlers.js.map +1 -1
  43. package/dist/outbound-schemas.d.ts +2 -1
  44. package/dist/outbound-schemas.js +3 -2
  45. package/dist/outbound-schemas.js.map +1 -1
  46. package/dist/quickstart.js +9 -3
  47. package/dist/quickstart.js.map +1 -1
  48. package/dist/remote-credential-check.d.ts +26 -7
  49. package/dist/remote-credential-check.js +157 -10
  50. package/dist/remote-credential-check.js.map +1 -1
  51. package/dist/settings-api.d.ts +17 -0
  52. package/dist/settings-api.js +62 -1
  53. package/dist/settings-api.js.map +1 -1
  54. package/dist/status-emojis.d.ts +118 -0
  55. package/dist/status-emojis.js +206 -0
  56. package/dist/status-emojis.js.map +1 -0
  57. package/dist/types.d.ts +12 -0
  58. package/dist/ui/dashboard.html +7 -5
  59. package/dist/ui/settings.html +168 -6
  60. package/dist/ui/view.html +138 -45
  61. package/dist/usage/usage-api.js +30 -0
  62. package/dist/usage/usage-api.js.map +1 -1
  63. package/package.json +1 -1
@@ -47,21 +47,50 @@ export type DeliveryStatusSelector = {
47
47
  deliveryId: string;
48
48
  operationId?: never;
49
49
  correlationId?: never;
50
+ messageId?: never;
50
51
  limit?: never;
51
52
  cursor?: never;
52
53
  } | {
53
54
  operationId: string;
54
55
  deliveryId?: never;
55
56
  correlationId?: never;
57
+ messageId?: never;
56
58
  limit?: number;
57
59
  cursor?: string;
58
60
  } | {
59
61
  correlationId: string;
60
62
  deliveryId?: never;
61
63
  operationId?: never;
64
+ messageId?: never;
65
+ limit?: number;
66
+ cursor?: string;
67
+ }
68
+ /**
69
+ * #856: the `message_id` a receiver sees in the envelope header. Lets an
70
+ * agent check that a peer message it is about to act on was really
71
+ * delivered by the fleet — a model can "see" one nobody sent.
72
+ */
73
+ | {
74
+ messageId: string;
75
+ deliveryId?: never;
76
+ operationId?: never;
77
+ correlationId?: never;
62
78
  limit?: number;
63
79
  cursor?: string;
64
80
  };
81
+ /** The one mapping from validated tool/CLI arguments to a selector. */
82
+ export declare function deliveryStatusSelector(args: {
83
+ delivery_id?: string;
84
+ operation_id?: string;
85
+ correlation_id?: string;
86
+ message_id?: string;
87
+ limit?: number;
88
+ cursor?: string;
89
+ }): DeliveryStatusSelector;
90
+ /** sha256 of a delivered text, as hex. The same function hashes admission and paste. */
91
+ export declare function deliveryContentDigest(text: string): string;
92
+ /** #926 reminders and overdue notices; never themselves the subject of a failure notice. */
93
+ export declare const REPLY_OBLIGATION_NOTICE_KIND = "reply_obligation_notice";
65
94
  export interface DeliveryStatusItem {
66
95
  delivery_id: string;
67
96
  operation_id: string;
@@ -76,6 +105,44 @@ export interface DeliveryStatusItem {
76
105
  status_summary: string;
77
106
  error_summary: string | null;
78
107
  safe_to_retry: boolean;
108
+ /** Fleet message ID of the delivered envelope, when it had one. */
109
+ message_id: string | null;
110
+ /** sha256 of the delivered text as admitted (#856). */
111
+ content_sha256: string | null;
112
+ /**
113
+ * The text as admitted — only on a `message_id` query by the row's own
114
+ * source or target: the caller already sent or received it, and verifying
115
+ * a value (a SHA, a PR number) needs the text, not just its digest. Every
116
+ * other query, and operator reads, stay redacted as before (#982).
117
+ */
118
+ content?: string | null;
119
+ /** #926: the reply this request is owed, when it asked for one. */
120
+ reply_obligation?: {
121
+ state: "open" | "answered";
122
+ opened_at: string;
123
+ last_asked_at: string;
124
+ nudged_at: string | null;
125
+ overdue_notified_at: string | null;
126
+ answered_at: string | null;
127
+ } | null;
128
+ }
129
+ /**
130
+ * #926: a peer asked with `requires_reply` and the owner has not answered it
131
+ * through the fleet yet. Terminal text is not an answer: only a message the
132
+ * owner sends back to the requester with the same correlation_id closes it.
133
+ */
134
+ export interface ReplyObligation {
135
+ correlationId: string;
136
+ requesterInstance: string;
137
+ ownerInstance: string;
138
+ requestDeliveryId: string;
139
+ openedAt: string;
140
+ lastAskedAt: string;
141
+ state: "open" | "answered";
142
+ nudgedAt: string | null;
143
+ overdueNotifiedAt: string | null;
144
+ answeredAt: string | null;
145
+ answeredDeliveryId: string | null;
79
146
  }
80
147
  export interface DeliveryStatusPage {
81
148
  items: DeliveryStatusItem[];
@@ -92,6 +159,10 @@ export interface DeliveryAttemptEvidence {
92
159
  transcriptSessionId: string | null;
93
160
  submissionMode: DurableSubmissionMode;
94
161
  queueResumePolicy?: QueueResumePolicy;
162
+ /** sha256 of the text the target daemon received, before formatting (#856). */
163
+ pastedContentSha256?: string | null;
164
+ /** sha256 of the exact string handed to the pane write (#856). */
165
+ pastedBytesSha256?: string | null;
95
166
  }
96
167
  export interface DeliveryReconciliationCandidate extends OutboxDelivery {
97
168
  attempt: {
@@ -116,6 +187,44 @@ export declare class DeliveryOutbox extends EventEmitter {
116
187
  private readonly db;
117
188
  readonly managerBootId: string;
118
189
  constructor(dbPath: string, managerBootId?: string);
190
+ /**
191
+ * #926: a delivered `requires_reply` request opens (or, asked again,
192
+ * re-opens) the owner's obligation. Runs inside the delivered transition.
193
+ * An answer admitted before the request finished its submission proof —
194
+ * an owner can reply while the daemon is still confirming — counts.
195
+ */
196
+ private openReplyObligation;
197
+ /** The owner's message back to the requester on the same correlation_id answers it — at admission. */
198
+ private answerReplyObligation;
199
+ /**
200
+ * #926 safety net 1: remind an owner that has not answered. `turn-ended`
201
+ * reminds once per ask, after the grace (a reply seconds after the ask is
202
+ * normal); `restart` reminds once per restart, because the interrupted turn
203
+ * does not resume on its own. Marked in the same transaction as the notice.
204
+ */
205
+ remindReplyObligations(ownerInstance: string, opts: {
206
+ now?: Date;
207
+ graceMs: number;
208
+ reason: "turn-ended" | "restart";
209
+ since?: Date;
210
+ }): number;
211
+ /**
212
+ * #926 safety net 2 (poll-on-timeout, in code): tell the requester once when
213
+ * the owner is not working and nothing has come back for `overdueMs` since
214
+ * the last ask or reminder. A still-working owner is never reported.
215
+ */
216
+ notifyOverdueReplyObligations(opts: {
217
+ now?: Date;
218
+ overdueMs: number;
219
+ ownerIdle: (owner: string) => boolean;
220
+ }): number;
221
+ /** A system notice as a durable row, like #929's outcome notices; the key makes it idempotent. */
222
+ private insertReplyObligationNotice;
223
+ /** The owner's open obligations, oldest first (#926). */
224
+ openReplyObligations(ownerInstance: string): ReplyObligation[];
225
+ getReplyObligation(correlationId: string, requesterInstance: string, ownerInstance: string): ReplyObligation | undefined;
226
+ /** Rows admitted before #856 get the same message_id / digest a new row would. */
227
+ private backfillMessageEvidence;
119
228
  private ensureColumn;
120
229
  /** Commit admission before returning success. Duplicate source keys return their original row. */
121
230
  admit(input: NewOutboxDelivery): {
@@ -1,11 +1,44 @@
1
1
  import Database from "better-sqlite3";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { EventEmitter } from "node:events";
4
4
  import { chmodSync, mkdirSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
6
  export const DURABLE_DELIVERY_MAX_ATTEMPTS = 8;
7
7
  export const DURABLE_DELIVERY_MAX_AGE_MS = 24 * 60 * 60_000;
8
8
  export const DURABLE_DELIVERY_ABORT_BACKOFF_MS = 5_000;
9
+ /** The one mapping from validated tool/CLI arguments to a selector. */
10
+ export function deliveryStatusSelector(args) {
11
+ const page = { ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.cursor ? { cursor: args.cursor } : {}) };
12
+ if (args.delivery_id)
13
+ return { deliveryId: args.delivery_id };
14
+ if (args.operation_id)
15
+ return { operationId: args.operation_id, ...page };
16
+ if (args.message_id)
17
+ return { messageId: args.message_id, ...page };
18
+ return { correlationId: args.correlation_id, ...page };
19
+ }
20
+ /** sha256 of a delivered text, as hex. The same function hashes admission and paste. */
21
+ export function deliveryContentDigest(text) {
22
+ return createHash("sha256").update(text, "utf8").digest("hex");
23
+ }
24
+ /** The text a durable row delivers, for kinds whose payload carries one. */
25
+ function payloadContent(payload) {
26
+ const content = payload && typeof payload === "object" ? payload.content : undefined;
27
+ return typeof content === "string" ? content : null;
28
+ }
29
+ /** #926 reminders and overdue notices; never themselves the subject of a failure notice. */
30
+ export const REPLY_OBLIGATION_NOTICE_KIND = "reply_obligation_notice";
31
+ /** Kinds that carry a peer request or its answer. Broadcasts are one-to-many and deferred (#926 §6). */
32
+ const REPLY_OBLIGATION_KINDS = ["fleet_inbound", "steer"];
33
+ function payloadRequiresReply(payload) {
34
+ const meta = payload && typeof payload === "object" ? payload.meta : undefined;
35
+ return !!meta && typeof meta === "object" && meta.requires_reply === "true";
36
+ }
37
+ function payloadMessageId(payload) {
38
+ const meta = payload && typeof payload === "object" ? payload.meta : undefined;
39
+ const id = meta && typeof meta === "object" ? meta.message_id : undefined;
40
+ return typeof id === "string" && id.length > 0 ? id : null;
41
+ }
9
42
  function mapRow(row) {
10
43
  return {
11
44
  deliveryId: row.delivery_id,
@@ -60,9 +93,42 @@ function statusSummary(state) {
60
93
  function safeToRetry(state) {
61
94
  return state === "queued" || state === "delivering" || state === "retry_wait" || state === "failed";
62
95
  }
96
+ function hasTable(db, table) {
97
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
98
+ }
99
+ /** The obligation a request row opened, if any; absent on databases from before #926. */
100
+ function obligationFor(db, correlationId, requester, owner) {
101
+ if (!correlationId || !hasTable(db, "reply_obligations"))
102
+ return null;
103
+ const row = db.prepare(`
104
+ SELECT state, opened_at, last_asked_at, nudged_at, overdue_notified_at, answered_at FROM reply_obligations
105
+ WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
106
+ `).get(correlationId, requester, owner);
107
+ return row ?? null;
108
+ }
109
+ function hasColumn(db, table, column) {
110
+ return db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === column);
111
+ }
112
+ /** Derived from the stored payload, so it is the same for pre-#856 rows. */
113
+ function rowContentEvidence(payloadJson, withContent) {
114
+ let payload = null;
115
+ try {
116
+ payload = JSON.parse(payloadJson);
117
+ }
118
+ catch { /* corrupt row: report no evidence */ }
119
+ const content = payloadContent(payload);
120
+ return {
121
+ message_id: payloadMessageId(payload),
122
+ content_sha256: content === null ? null : deliveryContentDigest(content),
123
+ ...(withContent ? { content } : {}),
124
+ };
125
+ }
63
126
  function queryStatusPage(db, selector, callerInstance) {
64
127
  const where = [];
65
128
  const params = [];
129
+ // An operator may read a database written before the column existed.
130
+ const messageIdColumn = hasColumn(db, "deliveries", "message_id")
131
+ ? "d.message_id" : "json_extract(d.payload_json,'$.meta.message_id')";
66
132
  if (selector.deliveryId) {
67
133
  where.push("d.delivery_id = ?");
68
134
  params.push(selector.deliveryId);
@@ -71,6 +137,10 @@ function queryStatusPage(db, selector, callerInstance) {
71
137
  where.push("d.operation_id = ?");
72
138
  params.push(selector.operationId);
73
139
  }
140
+ else if (selector.messageId) {
141
+ where.push(`${messageIdColumn} = ?`);
142
+ params.push(selector.messageId);
143
+ }
74
144
  else {
75
145
  where.push("d.correlation_id = ?");
76
146
  params.push(selector.correlationId);
@@ -86,6 +156,10 @@ function queryStatusPage(db, selector, callerInstance) {
86
156
  cursorScope.push("c.operation_id = ?");
87
157
  cursorParams.push(selector.operationId);
88
158
  }
159
+ else if (selector.messageId) {
160
+ cursorScope.push(`${messageIdColumn.replace(/\bd\./g, "c.")} = ?`);
161
+ cursorParams.push(selector.messageId);
162
+ }
89
163
  else {
90
164
  cursorScope.push("c.correlation_id = ?");
91
165
  cursorParams.push(selector.correlationId);
@@ -103,7 +177,7 @@ function queryStatusPage(db, selector, callerInstance) {
103
177
  d.kind,
104
178
  CASE WHEN d.state='submission_started' AND d.reconciliation_pending=1
105
179
  THEN 'reconciliation_pending' ELSE d.state END AS state,
106
- d.attempt_no,d.created_at,d.updated_at,d.last_error
180
+ d.attempt_no,d.created_at,d.updated_at,d.last_error,d.payload_json
107
181
  FROM deliveries d
108
182
  WHERE ${where.join(" AND ")}
109
183
  ORDER BY d.created_seq
@@ -126,10 +200,22 @@ function queryStatusPage(db, selector, callerInstance) {
126
200
  status_summary: statusSummary(row.state),
127
201
  error_summary: safeErrorSummary(row.last_error),
128
202
  safe_to_retry: safeToRetry(row.state),
203
+ // Text only for the explicit verification query (#856), and only to the
204
+ // row's own source/target; every other query keeps #982's redaction.
205
+ ...rowContentEvidence(row.payload_json, callerInstance !== null && !!selector.messageId),
206
+ reply_obligation: obligationFor(db, row.correlation_id, row.source_instance, row.target_instance),
129
207
  })),
130
208
  next_cursor: hasMore ? visible.at(-1)?.delivery_id ?? null : null,
131
209
  };
132
210
  }
211
+ function mapObligation(row) {
212
+ return {
213
+ correlationId: row.correlation_id, requesterInstance: row.requester_instance, ownerInstance: row.owner_instance,
214
+ requestDeliveryId: row.request_delivery_id, openedAt: row.opened_at, lastAskedAt: row.last_asked_at, state: row.state,
215
+ nudgedAt: row.nudged_at, overdueNotifiedAt: row.overdue_notified_at, answeredAt: row.answered_at,
216
+ answeredDeliveryId: row.answered_delivery_id,
217
+ };
218
+ }
133
219
  /** Durable admission and fenced state transitions for the fleet delivery queue. */
134
220
  export class DeliveryOutbox extends EventEmitter {
135
221
  db;
@@ -215,6 +301,21 @@ export class DeliveryOutbox extends EventEmitter {
215
301
  created_at TEXT NOT NULL,
216
302
  PRIMARY KEY (source_instance, source_daemon_boot_id, operation_id, target_instance)
217
303
  );
304
+ CREATE TABLE IF NOT EXISTS reply_obligations (
305
+ correlation_id TEXT NOT NULL,
306
+ requester_instance TEXT NOT NULL,
307
+ owner_instance TEXT NOT NULL,
308
+ request_delivery_id TEXT NOT NULL,
309
+ opened_at TEXT NOT NULL,
310
+ last_asked_at TEXT NOT NULL,
311
+ state TEXT NOT NULL CHECK (state IN ('open','answered')),
312
+ nudged_at TEXT,
313
+ overdue_notified_at TEXT,
314
+ answered_at TEXT,
315
+ answered_delivery_id TEXT,
316
+ PRIMARY KEY (correlation_id, requester_instance, owner_instance)
317
+ );
318
+ CREATE INDEX IF NOT EXISTS idx_reply_obligation_owner_state ON reply_obligations(owner_instance, state);
218
319
  CREATE TABLE IF NOT EXISTS failure_notices (
219
320
  parent_delivery_id TEXT PRIMARY KEY,
220
321
  notice_delivery_id TEXT NOT NULL UNIQUE,
@@ -233,7 +334,186 @@ export class DeliveryOutbox extends EventEmitter {
233
334
  this.ensureColumn("delivery_attempts", "backend_version", "TEXT");
234
335
  this.ensureColumn("delivery_attempts", "queue_resume_policy", "TEXT");
235
336
  this.ensureColumn("delivery_attempts", "enter_started_at", "TEXT");
236
- this.db.pragma("user_version = 3");
337
+ // #856: a receiver can look a message up by the ID in its envelope, and
338
+ // admission vs pane-write digests tell transport rewrites apart from a
339
+ // receiver acting on a message nobody sent.
340
+ this.ensureColumn("deliveries", "message_id", "TEXT");
341
+ this.ensureColumn("deliveries", "content_sha256", "TEXT");
342
+ this.ensureColumn("delivery_attempts", "pasted_content_sha256", "TEXT");
343
+ this.ensureColumn("delivery_attempts", "pasted_bytes_sha256", "TEXT");
344
+ this.ensureColumn("delivery_attempts", "content_digest_mismatch", "INTEGER NOT NULL DEFAULT 0");
345
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_message_id ON deliveries(message_id)");
346
+ this.backfillMessageEvidence();
347
+ this.db.pragma("user_version = 4");
348
+ }
349
+ /**
350
+ * #926: a delivered `requires_reply` request opens (or, asked again,
351
+ * re-opens) the owner's obligation. Runs inside the delivered transition.
352
+ * An answer admitted before the request finished its submission proof —
353
+ * an owner can reply while the daemon is still confirming — counts.
354
+ */
355
+ openReplyObligation(request, now) {
356
+ if (!REPLY_OBLIGATION_KINDS.includes(request.kind) || !request.correlation_id)
357
+ return;
358
+ let payload = null;
359
+ try {
360
+ payload = JSON.parse(request.payload_json);
361
+ }
362
+ catch {
363
+ return;
364
+ }
365
+ if (!payloadRequiresReply(payload))
366
+ return;
367
+ const earlyAnswer = this.db.prepare(`
368
+ SELECT delivery_id FROM deliveries
369
+ WHERE correlation_id=? AND source_instance=? AND target_instance=? AND created_seq > ?
370
+ AND kind IN (${REPLY_OBLIGATION_KINDS.map(() => "?").join(",")})
371
+ ORDER BY created_seq LIMIT 1
372
+ `).get(request.correlation_id, request.target_instance, request.source_instance, request.created_seq, ...REPLY_OBLIGATION_KINDS);
373
+ this.db.prepare(`
374
+ INSERT INTO reply_obligations(correlation_id, requester_instance, owner_instance, request_delivery_id,
375
+ opened_at, last_asked_at, state, answered_at, answered_delivery_id)
376
+ VALUES (@cid, @requester, @owner, @request, @now, @now, @state, @answeredAt, @answeredId)
377
+ ON CONFLICT(correlation_id, requester_instance, owner_instance) DO UPDATE SET
378
+ request_delivery_id=excluded.request_delivery_id, last_asked_at=excluded.last_asked_at,
379
+ state=excluded.state, nudged_at=NULL, overdue_notified_at=NULL,
380
+ answered_at=excluded.answered_at, answered_delivery_id=excluded.answered_delivery_id
381
+ `).run({
382
+ cid: request.correlation_id, requester: request.source_instance, owner: request.target_instance,
383
+ request: request.delivery_id, now,
384
+ state: earlyAnswer ? "answered" : "open",
385
+ answeredAt: earlyAnswer ? now : null, answeredId: earlyAnswer?.delivery_id ?? null,
386
+ });
387
+ }
388
+ /** The owner's message back to the requester on the same correlation_id answers it — at admission. */
389
+ answerReplyObligation(answer, now) {
390
+ if (!REPLY_OBLIGATION_KINDS.includes(answer.kind) || !answer.correlation_id)
391
+ return;
392
+ this.db.prepare(`
393
+ UPDATE reply_obligations SET state='answered', answered_at=?, answered_delivery_id=?
394
+ WHERE correlation_id=? AND owner_instance=? AND requester_instance=? AND state='open'
395
+ `).run(now, answer.delivery_id, answer.correlation_id, answer.source_instance, answer.target_instance);
396
+ }
397
+ /**
398
+ * #926 safety net 1: remind an owner that has not answered. `turn-ended`
399
+ * reminds once per ask, after the grace (a reply seconds after the ask is
400
+ * normal); `restart` reminds once per restart, because the interrupted turn
401
+ * does not resume on its own. Marked in the same transaction as the notice.
402
+ */
403
+ remindReplyObligations(ownerInstance, opts) {
404
+ const now = opts.now ?? new Date();
405
+ const nowIso = now.toISOString();
406
+ const graceCutoff = new Date(now.getTime() - opts.graceMs).toISOString();
407
+ const sinceIso = (opts.since ?? now).toISOString();
408
+ const inserted = this.db.transaction(() => {
409
+ const due = this.db.prepare(opts.reason === "turn-ended"
410
+ ? "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND nudged_at IS NULL AND last_asked_at <= ?"
411
+ : "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND (nudged_at IS NULL OR nudged_at < ?)").all(ownerInstance, opts.reason === "turn-ended" ? graceCutoff : sinceIso);
412
+ let count = 0;
413
+ for (const row of due) {
414
+ const content = opts.reason === "turn-ended"
415
+ ? `[system:reply-pending] Your turn ended without answering ${row.requester_instance} (correlation_id ${row.correlation_id}, asked ${row.last_asked_at}). Send your conclusion with report_result (correlation_id ${row.correlation_id}) now — text you write in the terminal does not reach them.`
416
+ : `[system:reply-pending] A restart interrupted your work on correlation_id ${row.correlation_id} from ${row.requester_instance} (asked ${row.last_asked_at}). Resume it and answer with report_result (correlation_id ${row.correlation_id}); terminal text does not reach them.`;
417
+ const key = `reply-pending:${opts.reason}:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${opts.reason === "turn-ended" ? row.last_asked_at : sinceIso}`;
418
+ if (this.insertReplyObligationNotice(row.owner_instance, row.correlation_id, key, content, nowIso))
419
+ count++;
420
+ this.db.prepare(`
421
+ UPDATE reply_obligations SET nudged_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
422
+ `).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
423
+ }
424
+ return count;
425
+ })();
426
+ if (inserted > 0)
427
+ this.emit("admitted");
428
+ return inserted;
429
+ }
430
+ /**
431
+ * #926 safety net 2 (poll-on-timeout, in code): tell the requester once when
432
+ * the owner is not working and nothing has come back for `overdueMs` since
433
+ * the last ask or reminder. A still-working owner is never reported.
434
+ */
435
+ notifyOverdueReplyObligations(opts) {
436
+ if (!(opts.overdueMs > 0))
437
+ return 0;
438
+ const now = opts.now ?? new Date();
439
+ const nowIso = now.toISOString();
440
+ const inserted = this.db.transaction(() => {
441
+ const open = this.db.prepare(`
442
+ SELECT * FROM reply_obligations WHERE state='open' AND overdue_notified_at IS NULL ORDER BY opened_at
443
+ `).all();
444
+ let count = 0;
445
+ for (const row of open) {
446
+ const quietSince = Math.max(Date.parse(row.last_asked_at), row.nudged_at ? Date.parse(row.nudged_at) : 0);
447
+ if (now.getTime() - quietSince < opts.overdueMs || !opts.ownerIdle(row.owner_instance))
448
+ continue;
449
+ const content = `[system:reply-overdue] ${row.owner_instance} has not answered correlation_id ${row.correlation_id} (asked ${row.last_asked_at}${row.nudged_at ? `, reminded ${row.nudged_at}` : ""}) and is not working on anything now. Check with describe_instance or delivery_status (correlation_id ${row.correlation_id}), or ask again.`;
450
+ const key = `reply-overdue:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${row.last_asked_at}`;
451
+ if (this.insertReplyObligationNotice(row.requester_instance, row.correlation_id, key, content, nowIso))
452
+ count++;
453
+ this.db.prepare(`
454
+ UPDATE reply_obligations SET overdue_notified_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
455
+ `).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
456
+ }
457
+ return count;
458
+ })();
459
+ if (inserted > 0)
460
+ this.emit("admitted");
461
+ return inserted;
462
+ }
463
+ /** A system notice as a durable row, like #929's outcome notices; the key makes it idempotent. */
464
+ insertReplyObligationNotice(target, correlationId, sourceKey, content, now) {
465
+ const noticeId = randomUUID();
466
+ const payload = {
467
+ type: "fleet_inbound",
468
+ content,
469
+ meta: {
470
+ user: "AgEnD delivery outbox", user_id: "agend-system", message_id: `reply-obligation-${noticeId}`,
471
+ chat_id: "", thread_id: "", source: "delivery-outbox",
472
+ },
473
+ };
474
+ return this.db.prepare(`
475
+ INSERT OR IGNORE INTO deliveries (
476
+ delivery_id,operation_id,source_key,source_instance,source_daemon_boot_id,
477
+ target_instance,target_session,target_daemon_boot_id,kind,correlation_id,payload_json,message_id,content_sha256,state,
478
+ attempt_no,created_seq,manager_boot_id,created_at,updated_at,accepted_at
479
+ ) VALUES (?,?,?,'agend-system',?,?,NULL,NULL,?,?,?,?,?,'queued',0,
480
+ (SELECT COALESCE(MAX(created_seq),0)+1 FROM deliveries),NULL,?,?,?)
481
+ `).run(noticeId, `notice:${sourceKey}`, sourceKey, this.managerBootId, target, REPLY_OBLIGATION_NOTICE_KIND, correlationId, JSON.stringify(payload), payload.meta.message_id, deliveryContentDigest(content), now, now, now).changes === 1;
482
+ }
483
+ /** The owner's open obligations, oldest first (#926). */
484
+ openReplyObligations(ownerInstance) {
485
+ const rows = this.db.prepare(`
486
+ SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' ORDER BY opened_at, correlation_id
487
+ `).all(ownerInstance);
488
+ return rows.map(mapObligation);
489
+ }
490
+ getReplyObligation(correlationId, requesterInstance, ownerInstance) {
491
+ const row = this.db.prepare(`
492
+ SELECT * FROM reply_obligations WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
493
+ `).get(correlationId, requesterInstance, ownerInstance);
494
+ return row ? mapObligation(row) : undefined;
495
+ }
496
+ /** Rows admitted before #856 get the same message_id / digest a new row would. */
497
+ backfillMessageEvidence() {
498
+ const rows = this.db.prepare(`
499
+ SELECT delivery_id, payload_json FROM deliveries WHERE message_id IS NULL AND content_sha256 IS NULL
500
+ `).all();
501
+ if (rows.length === 0)
502
+ return;
503
+ const update = this.db.prepare("UPDATE deliveries SET message_id=?, content_sha256=? WHERE delivery_id=?");
504
+ this.db.transaction(() => {
505
+ for (const row of rows) {
506
+ let payload = null;
507
+ try {
508
+ payload = JSON.parse(row.payload_json);
509
+ }
510
+ catch {
511
+ continue;
512
+ }
513
+ const content = payloadContent(payload);
514
+ update.run(payloadMessageId(payload), content === null ? null : deliveryContentDigest(content), row.delivery_id);
515
+ }
516
+ })();
237
517
  }
238
518
  ensureColumn(table, column, definition) {
239
519
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
@@ -248,11 +528,11 @@ export class DeliveryOutbox extends EventEmitter {
248
528
  const insert = this.db.prepare(`
249
529
  INSERT OR IGNORE INTO deliveries (
250
530
  delivery_id, operation_id, source_key, source_instance, source_daemon_boot_id,
251
- target_instance, target_session, kind, correlation_id, payload_json, state,
531
+ target_instance, target_session, kind, correlation_id, payload_json, message_id, content_sha256, state,
252
532
  attempt_no, created_seq, manager_boot_id, created_at, updated_at, accepted_at
253
533
  ) VALUES (
254
534
  @delivery_id, @operation_id, @source_key, @source_instance, @source_daemon_boot_id,
255
- @target_instance, @target_session, @kind, @correlation_id, @payload_json, 'queued',
535
+ @target_instance, @target_session, @kind, @correlation_id, @payload_json, @message_id, @content_sha256, 'queued',
256
536
  0, (SELECT COALESCE(MAX(created_seq), 0) + 1 FROM deliveries), NULL,
257
537
  @created_at, @updated_at, @accepted_at
258
538
  )
@@ -270,6 +550,8 @@ export class DeliveryOutbox extends EventEmitter {
270
550
  kind: input.kind,
271
551
  correlation_id: input.correlationId ?? null,
272
552
  payload_json: JSON.stringify(input.payload),
553
+ message_id: payloadMessageId(input.payload),
554
+ content_sha256: (content => content === null ? null : deliveryContentDigest(content))(payloadContent(input.payload)),
273
555
  created_at: now,
274
556
  updated_at: now,
275
557
  accepted_at: now,
@@ -277,6 +559,8 @@ export class DeliveryOutbox extends EventEmitter {
277
559
  const row = select.get(input.sourceKey);
278
560
  if (!row)
279
561
  throw new Error("outbox admission committed without a readable row");
562
+ if (result.changes === 1)
563
+ this.answerReplyObligation(row, now);
280
564
  return { delivery: mapRow(row), inserted: result.changes === 1 };
281
565
  });
282
566
  return transaction();
@@ -390,6 +674,10 @@ export class DeliveryOutbox extends EventEmitter {
390
674
  const started = this.db.prepare(`
391
675
  SELECT COUNT(*) AS count FROM delivery_attempts WHERE delivery_id=?
392
676
  `).get(deliveryId);
677
+ // Only a digest on BOTH sides can disagree: a pre-#856 row or a caller
678
+ // that could not hash is "unknown", never a mismatch.
679
+ const mismatch = !!row.content_sha256 && !!evidence?.pastedContentSha256
680
+ && row.content_sha256 !== evidence.pastedContentSha256;
393
681
  if (started.count >= DURABLE_DELIVERY_MAX_ATTEMPTS) {
394
682
  const update = this.db.prepare(`
395
683
  UPDATE deliveries SET state='failed',updated_at=?,finished_at=?,last_error='delivery submission attempt limit reached'
@@ -404,16 +692,19 @@ export class DeliveryOutbox extends EventEmitter {
404
692
  INSERT INTO delivery_attempts(
405
693
  delivery_id,target_daemon_boot_id,attempt_no,state,begin_ack_at,
406
694
  backend,window_id,transcript_path,transcript_offset,transcript_session_id,submission_mode,
407
- backend_version,queue_resume_policy
408
- ) VALUES (?,?,?,'begun',?,?,?,?,?,?,?,?,?)
409
- `).run(deliveryId, targetBootId, attemptNo, now, evidence?.backend ?? null, evidence?.windowId ?? null, evidence?.transcriptPath ?? null, evidence?.transcriptOffset ?? null, evidence?.transcriptSessionId ?? null, evidence?.submissionMode ?? null, evidence?.backendVersion ?? null, evidence?.queueResumePolicy ?? null);
695
+ backend_version,queue_resume_policy,pasted_content_sha256,pasted_bytes_sha256,content_digest_mismatch
696
+ ) VALUES (?,?,?,'begun',?,?,?,?,?,?,?,?,?,?,?,?)
697
+ `).run(deliveryId, targetBootId, attemptNo, now, evidence?.backend ?? null, evidence?.windowId ?? null, evidence?.transcriptPath ?? null, evidence?.transcriptOffset ?? null, evidence?.transcriptSessionId ?? null, evidence?.submissionMode ?? null, evidence?.backendVersion ?? null, evidence?.queueResumePolicy ?? null, evidence?.pastedContentSha256 ?? null, evidence?.pastedBytesSha256 ?? null, mismatch ? 1 : 0);
410
698
  this.db.prepare(`
411
699
  UPDATE deliveries SET state='submission_started', submitted_at=?, updated_at=?
412
700
  WHERE delivery_id=? AND state='delivering'
413
701
  `).run(now, now, deliveryId);
414
- return { outcome: "begun", failed: false };
702
+ return { outcome: "begun", failed: false, mismatch };
415
703
  });
416
704
  const result = transaction();
705
+ if (result.mismatch) {
706
+ this.emit("content_digest_mismatch", { deliveryId, targetBootId, attemptNo, targetInstance: this.get(deliveryId)?.targetInstance });
707
+ }
417
708
  if (result.outcome === "begun")
418
709
  this.emit("state", { deliveryId, state: "submission_started" });
419
710
  else if (result.failed)
@@ -490,6 +781,8 @@ export class DeliveryOutbox extends EventEmitter {
490
781
  if (parent && (outcome === "failed" || outcome === "uncertain")) {
491
782
  this.insertFailureNotice(parent, outcome, evidence ?? outcome, now);
492
783
  }
784
+ if (parent && outcome === "delivered")
785
+ this.openReplyObligation(parent, now);
493
786
  return { changed: true, accepted: true };
494
787
  });
495
788
  const result = transaction();
@@ -767,6 +1060,8 @@ export class DeliveryOutbox extends EventEmitter {
767
1060
  `).run(attemptState, outcome === "retry_wait" ? now : null, terminal ? now : null, evidence.slice(0, 300), deliveryId, targetBootId, attemptNo);
768
1061
  if (outcome === "uncertain")
769
1062
  this.insertFailureNotice(parent, "uncertain", evidence, now);
1063
+ if (outcome === "delivered")
1064
+ this.openReplyObligation(parent, now);
770
1065
  return true;
771
1066
  });
772
1067
  const changed = transaction();
@@ -790,7 +1085,8 @@ export class DeliveryOutbox extends EventEmitter {
790
1085
  }
791
1086
  /** Must be called inside the same SQLite transaction as the terminal transition. */
792
1087
  insertFailureNotice(parent, outcome, reason, now) {
793
- if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice")
1088
+ if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice"
1089
+ || parent.kind === REPLY_OBLIGATION_NOTICE_KIND)
794
1090
  return;
795
1091
  const noticeId = randomUUID();
796
1092
  const operationId = `notice:${parent.operation_id}:${parent.delivery_id}`;