@raidiant/notifai 1.1.0 → 2.0.1

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.
package/dist/hooks.d.ts CHANGED
@@ -76,6 +76,17 @@ export interface SessionState {
76
76
  * reaches the harness replays it instead of erasing it.
77
77
  */
78
78
  accepted?: AcceptedAnswerDelivery;
79
+ /**
80
+ * Required Agent Acknowledgements that the resumed agent still owes. This is
81
+ * separate from answer delivery: the answer journal may settle as soon as a
82
+ * harness accepts the continuation, while this obligation must survive until
83
+ * the service confirms the agent-authored follow-up exists.
84
+ */
85
+ acknowledgement_due?: AcknowledgementDue[];
86
+ }
87
+ export interface AcknowledgementDue {
88
+ request_id: string;
89
+ recorded_at: number;
79
90
  }
80
91
  interface AcceptedAnswerDelivery {
81
92
  answers: AnsweredPending[];
@@ -288,7 +299,7 @@ export interface HookContext {
288
299
  * the user sees in the terminal — but it is free to be rewritten, and a log a
289
300
  * filter cannot rely on is a log nobody filters.
290
301
  */
291
- export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'delivery-limit' | 'notifications-off' | 'claimed-elsewhere' | 'no-devices' | 'proceeding';
302
+ export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'delivery-limit' | 'acknowledgement-required' | 'notifications-off' | 'claimed-elsewhere' | 'no-devices' | 'proceeding';
292
303
  export type HookHarness = Harness;
293
304
  /**
294
305
  * Everything retirement needs. Narrower than a HookContext on purpose:
@@ -331,7 +342,10 @@ interface AnsweredPending {
331
342
  pending: PendingQuestion;
332
343
  reply: ReplyView;
333
344
  replies: ReplyView[];
345
+ /** Immutable server snapshot for this request, known after a replies/close response. */
346
+ agent_acknowledgement_required?: boolean | undefined;
334
347
  }
348
+ export declare function clearAcknowledgementObligation(sessionId: string, env: NodeJS.ProcessEnv, requestId: string): boolean;
335
349
  /**
336
350
  * Records presence and retires anything still asking on companion devices. This is the
337
351
  * "answered in the terminal" case from the original design: we cannot tell
package/dist/hooks.js CHANGED
@@ -541,7 +541,7 @@ async function answerableDevices(ctx) {
541
541
  return devices
542
542
  .filter((device) => (device.platform === 'ios' || device.platform === 'macos') &&
543
543
  device.registration_healthy &&
544
- device.reply_protocol_version === 1)
544
+ device.reply_protocol_version === 2)
545
545
  .filter((device) => configured === null || configured.includes(device.device_id))
546
546
  .map((device) => device.device_id);
547
547
  }
@@ -814,7 +814,14 @@ async function finalizePendings(ctx, pending) {
814
814
  function finalizedAnswer(finalized) {
815
815
  const replies = finalized.response?.replies ?? [];
816
816
  const reply = replies.at(-1);
817
- return reply === undefined ? null : { pending: finalized.pending, reply, replies };
817
+ return reply === undefined
818
+ ? null
819
+ : {
820
+ pending: finalized.pending,
821
+ reply,
822
+ replies,
823
+ agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
824
+ };
818
825
  }
819
826
  /** Persist and surface one answer without letting the two representations drift. */
820
827
  function reportAnswer(ctx, notes, answered, late) {
@@ -902,12 +909,35 @@ function answerContext(answered) {
902
909
  * tied to the question that asked it, with a truthful note about anything
903
910
  * still waiting.
904
911
  */
912
+ function acknowledgementCommand(requestId) {
913
+ return `notifai acknowledge ${requestId} --text <text>`;
914
+ }
915
+ function acknowledgementContext(answered) {
916
+ const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
917
+ if (due.length === 0) {
918
+ return ' Agent Acknowledgement is not required for the answered request(s).';
919
+ }
920
+ if (due.length === 1) {
921
+ const requestId = due[0].pending.request_id;
922
+ return (` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
923
+ `\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`);
924
+ }
925
+ const commands = due
926
+ .map((entry) => {
927
+ const requestId = entry.pending.request_id;
928
+ return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``;
929
+ })
930
+ .join('\n');
931
+ return (` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
932
+ commands);
933
+ }
905
934
  function answersContext(answered, remaining) {
906
935
  const tail = remaining > 0
907
936
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
908
937
  : '';
938
+ const guidance = acknowledgementContext(answered);
909
939
  if (answered.length === 1) {
910
- return answerContext(answered[0]) + tail;
940
+ return answerContext(answered[0]) + tail + guidance;
911
941
  }
912
942
  const lines = answered.map(({ pending, replies }) => {
913
943
  const latest = replies.at(-1);
@@ -918,7 +948,7 @@ function answersContext(answered, remaining) {
918
948
  const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `;
919
949
  return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`;
920
950
  });
921
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`;
951
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`;
922
952
  }
923
953
  function stopAnswerOutput(context) {
924
954
  return JSON.stringify({
@@ -940,6 +970,15 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
940
970
  };
941
971
  updateSessionState(sessionId, ctx.env, (current) => {
942
972
  const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
973
+ const acknowledgementDue = [...(current.acknowledgement_due ?? [])];
974
+ for (const answer of answered) {
975
+ const requestId = answer.pending.request_id;
976
+ if (answer.agent_acknowledgement_required === true &&
977
+ requestId !== undefined &&
978
+ !acknowledgementDue.some((entry) => entry.request_id === requestId)) {
979
+ acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at });
980
+ }
981
+ }
943
982
  const next = {
944
983
  ...current,
945
984
  accepted,
@@ -948,6 +987,10 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
948
987
  next.pending = pendingRemaining;
949
988
  else
950
989
  delete next.pending;
990
+ if (acknowledgementDue.length > 0)
991
+ next.acknowledgement_due = acknowledgementDue;
992
+ else
993
+ delete next.acknowledgement_due;
951
994
  return next;
952
995
  });
953
996
  return accepted;
@@ -956,6 +999,58 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
956
999
  function amendAcceptedAnswers(ctx, sessionId, amend) {
957
1000
  updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
958
1001
  }
1002
+ export function clearAcknowledgementObligation(sessionId, env, requestId) {
1003
+ let cleared = false;
1004
+ updateSessionState(sessionId, env, (current) => {
1005
+ const due = current.acknowledgement_due ?? [];
1006
+ const remaining = due.filter((entry) => entry.request_id !== requestId);
1007
+ cleared = remaining.length !== due.length;
1008
+ if (!cleared)
1009
+ return current;
1010
+ const next = { ...current };
1011
+ if (remaining.length > 0)
1012
+ next.acknowledgement_due = remaining;
1013
+ else
1014
+ delete next.acknowledgement_due;
1015
+ return next;
1016
+ });
1017
+ return cleared;
1018
+ }
1019
+ function acknowledgementBlockContext(due) {
1020
+ const commands = due
1021
+ .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1022
+ .join('\n');
1023
+ return (`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1024
+ `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`);
1025
+ }
1026
+ async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
1027
+ const unresolved = [];
1028
+ for (const obligation of due) {
1029
+ try {
1030
+ const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
1031
+ waitSeconds: 0,
1032
+ });
1033
+ if (snapshot.agent_acknowledgement_required === false ||
1034
+ snapshot.agent_acknowledgement !== null) {
1035
+ clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id);
1036
+ }
1037
+ else {
1038
+ unresolved.push(obligation);
1039
+ }
1040
+ }
1041
+ catch (err) {
1042
+ unresolved.push(obligation);
1043
+ ctx.log?.error('hook.gate', {
1044
+ verdict: 'held',
1045
+ reason: 'acknowledgement-required',
1046
+ stage: 'reconcile-failed',
1047
+ request_id: obligation.request_id,
1048
+ message: err instanceof Error ? err.message : String(err),
1049
+ });
1050
+ }
1051
+ }
1052
+ return unresolved;
1053
+ }
959
1054
  /**
960
1055
  * Retire the answered questions and close the journal.
961
1056
  *
@@ -1050,7 +1145,13 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1050
1145
  if (response === null) {
1051
1146
  notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
1052
1147
  }
1053
- return { ...entry, replies, reply: replies.at(-1) };
1148
+ return {
1149
+ ...entry,
1150
+ replies,
1151
+ reply: replies.at(-1),
1152
+ agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1153
+ entry.agent_acknowledgement_required,
1154
+ };
1054
1155
  });
1055
1156
  if (answered.length > 0) {
1056
1157
  stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
@@ -1164,6 +1265,23 @@ export async function runEscalationWaiter(ctx, options) {
1164
1265
  let state = readSessionState(sessionId, ctx.env);
1165
1266
  if (state.accepted !== undefined) {
1166
1267
  const accepted = state.accepted;
1268
+ if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
1269
+ const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
1270
+ if (due.length > 0) {
1271
+ gate(ctx, 'held', 'acknowledgement-required', {
1272
+ request_ids: due.map((entry) => entry.request_id),
1273
+ });
1274
+ return {
1275
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1276
+ notes,
1277
+ log: {
1278
+ stage: 'acknowledgement-required',
1279
+ request_ids: due.map((entry) => entry.request_id),
1280
+ },
1281
+ };
1282
+ }
1283
+ state = readSessionState(sessionId, ctx.env);
1284
+ }
1167
1285
  // Acknowledgement is per route, because routes end in different places.
1168
1286
  //
1169
1287
  // A blocking continuation ends at this process's stdout, which the
@@ -1184,6 +1302,39 @@ export async function runEscalationWaiter(ctx, options) {
1184
1302
  settleAcceptedAnswers(ctx, sessionId, accepted);
1185
1303
  settledAnswerThisPass = true;
1186
1304
  state = readSessionState(sessionId, ctx.env);
1305
+ const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
1306
+ const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
1307
+ if (due.length > 0) {
1308
+ gate(ctx, 'held', 'acknowledgement-required', {
1309
+ request_ids: due.map((entry) => entry.request_id),
1310
+ });
1311
+ return {
1312
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1313
+ notes,
1314
+ log: {
1315
+ stage: 'acknowledgement-required',
1316
+ request_ids: due.map((entry) => entry.request_id),
1317
+ },
1318
+ };
1319
+ }
1320
+ state = readSessionState(sessionId, ctx.env);
1321
+ }
1322
+ if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
1323
+ const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due);
1324
+ if (due.length > 0) {
1325
+ gate(ctx, 'held', 'acknowledgement-required', {
1326
+ request_ids: due.map((entry) => entry.request_id),
1327
+ });
1328
+ return {
1329
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1330
+ notes,
1331
+ log: {
1332
+ stage: 'acknowledgement-required',
1333
+ request_ids: due.map((entry) => entry.request_id),
1334
+ },
1335
+ };
1336
+ }
1337
+ state = readSessionState(sessionId, ctx.env);
1187
1338
  }
1188
1339
  const pending = pendingList(state);
1189
1340
  if (pending.length === 0) {
@@ -1332,7 +1483,13 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1332
1483
  if (response === null) {
1333
1484
  notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
1334
1485
  }
1335
- return { ...entry, replies, reply: replies.at(-1) };
1486
+ return {
1487
+ ...entry,
1488
+ replies,
1489
+ reply: replies.at(-1),
1490
+ agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1491
+ entry.agent_acknowledgement_required,
1492
+ };
1336
1493
  });
1337
1494
  const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
1338
1495
  for (const answer of authoritative)
@@ -1666,7 +1823,12 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1666
1823
  // unreachable, never discard the durable reply already observed.
1667
1824
  const finalReplies = finalized.response?.replies ?? [];
1668
1825
  const authoritative = finalReplies.length > 0 ? finalReplies : replies;
1669
- answered.push({ pending: entry, reply: authoritative.at(-1), replies: authoritative });
1826
+ answered.push({
1827
+ pending: entry,
1828
+ reply: authoritative.at(-1),
1829
+ replies: authoritative,
1830
+ agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
1831
+ });
1670
1832
  if (finalized.response === null) {
1671
1833
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
1672
1834
  }
@@ -1717,8 +1879,13 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1717
1879
  orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
1718
1880
  notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1719
1881
  }
1720
- if (state.accepted !== undefined) {
1721
- const preserved = { accepted: state.accepted };
1882
+ if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
1883
+ const preserved = {};
1884
+ if (state.accepted !== undefined)
1885
+ preserved.accepted = state.accepted;
1886
+ if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
1887
+ preserved.acknowledgement_due = state.acknowledgement_due;
1888
+ }
1722
1889
  if (state.last_prompt_at !== undefined)
1723
1890
  preserved.last_prompt_at = state.last_prompt_at;
1724
1891
  if (state.last_stop_at !== undefined)
@@ -1726,13 +1893,16 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1726
1893
  if (state.continuation !== undefined)
1727
1894
  preserved.continuation = state.continuation;
1728
1895
  writeSessionState(sessionId, env, preserved);
1729
- notes.push('preserved an accepted device answer for this exact session to resume');
1896
+ notes.push(state.accepted !== undefined
1897
+ ? 'preserved an accepted device answer for this exact session to resume'
1898
+ : 'preserved required Agent Acknowledgement obligations for this exact session');
1730
1899
  return {
1731
1900
  notes,
1732
1901
  log: {
1733
- outcome: 'answer-preserved',
1902
+ outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
1734
1903
  queued_retirements: orphans.length,
1735
- accepted_answers: state.accepted.answers.length,
1904
+ accepted_answers: state.accepted?.answers.length ?? 0,
1905
+ acknowledgement_due: state.acknowledgement_due?.length ?? 0,
1736
1906
  },
1737
1907
  };
1738
1908
  }