@canonmsg/codex-plugin 0.22.0 → 0.22.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/host.d.ts CHANGED
@@ -70,5 +70,17 @@ export declare function buildCodexRuntimeDescriptor(input: {
70
70
  supportsRichCards?: boolean;
71
71
  skills?: ReadonlyArray<CodexSkillMetadata>;
72
72
  }): CanonRuntimeDescriptor;
73
+ export declare function buildCodexTurnResponseRouting(input: {
74
+ requestingUserId?: string | null;
75
+ ownerId?: string | null;
76
+ ownerOnly?: boolean;
77
+ }): {
78
+ responseUserId?: string;
79
+ allowSessionRule: boolean;
80
+ };
81
+ export declare function getCodexRequestingUserId(message: {
82
+ senderId: string;
83
+ senderType?: 'human' | 'ai_agent';
84
+ }): string | null;
73
85
  export declare function main(): Promise<void>;
74
86
  export {};
package/dist/host.js CHANGED
@@ -6,7 +6,7 @@ import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
8
  import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { decideAutoReply, } from './inbound-policy.js';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter } from './app-server-adapter.js';
@@ -19,6 +19,7 @@ import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodex
19
19
  import { startCodexStreamInBackground } from './host-lifecycle.js';
20
20
  import { createCodexControlPoller } from './control-channel.js';
21
21
  import { runCli } from './cli-entry.js';
22
+ import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
22
23
  import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
23
24
  const HELP = `canon-codex — run a local Codex agent host for Canon
24
25
 
@@ -228,6 +229,18 @@ export function buildCodexRuntimeDescriptor(input) {
228
229
  coreControls: descriptor.coreControls.filter((control) => control.id !== 'model'),
229
230
  };
230
231
  }
232
+ export function buildCodexTurnResponseRouting(input) {
233
+ const responseUserId = input.ownerOnly
234
+ ? input.ownerId ?? undefined
235
+ : input.requestingUserId ?? input.ownerId ?? undefined;
236
+ return {
237
+ ...(responseUserId ? { responseUserId } : {}),
238
+ allowSessionRule: Boolean(responseUserId && input.ownerId && responseUserId === input.ownerId),
239
+ };
240
+ }
241
+ export function getCodexRequestingUserId(message) {
242
+ return message.senderType === 'human' ? message.senderId : null;
243
+ }
231
244
  function modelOptionLabel(model) {
232
245
  if (/^gpt-5\.5(?:$|[-_:])/i.test(model))
233
246
  return 'GPT-5.5';
@@ -534,7 +547,10 @@ export async function main() {
534
547
  kind: 'approval',
535
548
  generateId: () => randomUUID(),
536
549
  create: async ({ client: apiClient, ctx, conversationId, requestId, payload, expiresAt }) => {
537
- await apiClient.createRuntimeApprovalRequest({
550
+ const responseUserId = (payload.responseUserId ?? ctx.ownerId) || undefined;
551
+ const allowSessionRule = payload.allowSessionRule !== false
552
+ && Boolean(responseUserId && responseUserId === ctx.ownerId);
553
+ const created = await apiClient.createRuntimeApprovalRequest({
538
554
  conversationId,
539
555
  approvalId: requestId,
540
556
  toolName: payload.toolName,
@@ -545,14 +561,17 @@ export async function main() {
545
561
  native: payload.native,
546
562
  details: payload.details,
547
563
  ...(payload.diff ? { diff: payload.diff } : {}),
548
- responseUserId: ctx.ownerId || undefined,
549
- allowSessionRule: true,
564
+ ...(responseUserId ? { responseUserId } : {}),
565
+ allowSessionRule,
550
566
  expiresAt,
551
567
  ...(payload.turnId ? { turnId: payload.turnId } : {}),
552
568
  });
553
- return { requestId };
569
+ return {
570
+ requestId,
571
+ state: { allowSessionRule: created.allowSessionRule ?? allowSessionRule },
572
+ };
554
573
  },
555
- consume: async ({ client: apiClient, conversationId, requestId }) => {
574
+ consume: async ({ client: apiClient, conversationId, requestId, state }) => {
556
575
  const response = await apiClient
557
576
  .consumeRuntimeApprovalResponse({ conversationId, approvalId: requestId })
558
577
  .catch(() => null);
@@ -561,7 +580,9 @@ export async function main() {
561
580
  state: 'resolved',
562
581
  result: {
563
582
  decision: 'allow',
564
- ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
583
+ ...(state?.allowSessionRule === true && response.sessionRule
584
+ ? { sessionRule: response.sessionRule }
585
+ : {}),
565
586
  },
566
587
  };
567
588
  }
@@ -609,6 +630,7 @@ export async function main() {
609
630
  });
610
631
  const sessions = new Map();
611
632
  const pendingSessionCreations = new Map();
633
+ let inboundRecoverySequence = 0;
612
634
  const conversationCache = new Map();
613
635
  const knownConversationIds = new Set();
614
636
  const promptedGroupContextConversationIds = new Set();
@@ -754,6 +776,29 @@ export async function main() {
754
776
  return;
755
777
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
756
778
  }
779
+ function persistInboundRecoveryCursor(conversationId, messageId) {
780
+ if (!messageId)
781
+ return;
782
+ try {
783
+ saveRuntimeSessionState(runtimeId, {
784
+ conversationId,
785
+ baseCwd: workingDir,
786
+ lastInboundMessageId: messageId,
787
+ });
788
+ }
789
+ catch (error) {
790
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
791
+ }
792
+ }
793
+ function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
794
+ const session = sessions.get(conversationId);
795
+ // A later queued turn will advance past this non-triggering message. Do
796
+ // not write it ahead of earlier work and then let that work regress the
797
+ // cursor when it completes.
798
+ if (session?.running || (session?.queue.length ?? 0) > 0)
799
+ return;
800
+ persistInboundRecoveryCursor(conversationId, messageId);
801
+ }
757
802
  async function markQueuedPromptsRejected(conversationId, prompts) {
758
803
  await Promise.all(prompts.map((prompt) => {
759
804
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -761,6 +806,18 @@ export async function main() {
761
806
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
762
807
  }));
763
808
  }
809
+ function rememberDroppedRecoveryCursor(session, prompts) {
810
+ const latest = prompts.reduce((current, prompt) => (prompt.sourceMessageId && (!current || prompt.recoverySequence > current.recoverySequence)
811
+ ? prompt
812
+ : current), null)?.sourceMessageId;
813
+ if (!latest)
814
+ return;
815
+ if (session.running) {
816
+ session.pendingDroppedRecoveryCursor = latest;
817
+ return;
818
+ }
819
+ persistInboundRecoveryCursor(session.conversationId, latest);
820
+ }
764
821
  function removeQueuedPrompt(conversationId, sourceMessageId) {
765
822
  const session = sessions.get(conversationId);
766
823
  if (!session || session.queue.length === 0)
@@ -888,6 +945,7 @@ export async function main() {
888
945
  session.resetRequested = true;
889
946
  const droppedPrompts = session.queue.splice(0);
890
947
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
948
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
891
949
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
892
950
  session.adapter.clearThreadId();
893
951
  session.activeSelfContextId = null;
@@ -1012,6 +1070,7 @@ export async function main() {
1012
1070
  currentTurnCanUseCodexAppTools: false,
1013
1071
  activeSelfContextId: null,
1014
1072
  lastAcceptedIntent: null,
1073
+ pendingDroppedRecoveryCursor: null,
1015
1074
  resetRequested: false,
1016
1075
  lastActivity: Date.now(),
1017
1076
  typingKeepaliveTimer: null,
@@ -1040,7 +1099,7 @@ export async function main() {
1040
1099
  pendingSessionCreations.delete(conversationId);
1041
1100
  }
1042
1101
  }
1043
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
1102
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false, requestingUserId = null) {
1044
1103
  const nextPrompt = {
1045
1104
  prompt,
1046
1105
  intent,
@@ -1051,6 +1110,8 @@ export async function main() {
1051
1110
  planMode,
1052
1111
  artifactRoutingMode,
1053
1112
  canUseCodexAppTools,
1113
+ requestingUserId,
1114
+ recoverySequence: ++inboundRecoverySequence,
1054
1115
  };
1055
1116
  if (toFront) {
1056
1117
  session.queue.unshift(nextPrompt);
@@ -1076,7 +1137,7 @@ export async function main() {
1076
1137
  const args = isRecord(params.arguments) ? params.arguments : null;
1077
1138
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1078
1139
  }
1079
- async function handleCodexServerRequest(session, request) {
1140
+ async function handleCodexServerRequest(session, request, requestingUserId) {
1080
1141
  const requestId = String(request.id);
1081
1142
  const params = request.params;
1082
1143
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1108,11 +1169,10 @@ export async function main() {
1108
1169
  ?? requestId;
1109
1170
  let requestCreated = false;
1110
1171
  let requestResolved = false;
1172
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1111
1173
  try {
1112
- // Built-in card descriptor owns create+poll; codex passes native/turnId
1113
- // via payload, post-create turn side effects via onCreated. responseUserId
1114
- // is omitted (default `infer` policy) so the backend targets a reachable
1115
- // member (owner is often not a member of agent-to-user DMs).
1174
+ // Built-in card descriptor owns create+poll; Codex passes the native
1175
+ // handles, turn context, and the human responder captured for this turn.
1116
1176
  const cardResult = await runtimeRequests.request('card', session.conversationId, {
1117
1177
  card,
1118
1178
  native: {
@@ -1126,6 +1186,9 @@ export async function main() {
1126
1186
  },
1127
1187
  },
1128
1188
  turnId: session.currentTurnId ?? undefined,
1189
+ ...(responseRouting.responseUserId
1190
+ ? { responseUserId: responseRouting.responseUserId }
1191
+ : {}),
1129
1192
  }, {
1130
1193
  requestId: cardId,
1131
1194
  expiresAt,
@@ -1199,8 +1262,12 @@ export async function main() {
1199
1262
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1200
1263
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1201
1264
  const inputId = readString(params, 'itemId') ?? requestId;
1202
- // Built-in input descriptor owns create+poll; default `owner` responder
1203
- // policy resolves to ctx.ownerId (matching the former responseUserId).
1265
+ const sensitive = Boolean(questions?.some((question) => question.isSecret));
1266
+ const responseRouting = buildCodexTurnResponseRouting({
1267
+ requestingUserId,
1268
+ ownerId,
1269
+ ownerOnly: sensitive,
1270
+ });
1204
1271
  const response = await runtimeRequests.request('input', session.conversationId, {
1205
1272
  kind: 'clarify',
1206
1273
  title: 'Codex needs input',
@@ -1208,7 +1275,10 @@ export async function main() {
1208
1275
  ? 'Codex needs your input to continue.'
1209
1276
  : 'Codex needs input.',
1210
1277
  ...(questions ? { questions } : {}),
1211
- sensitive: Boolean(questions?.some((question) => question.isSecret)),
1278
+ sensitive,
1279
+ ...(responseRouting.responseUserId
1280
+ ? { responseUserId: responseRouting.responseUserId }
1281
+ : {}),
1212
1282
  native: {
1213
1283
  runtime: 'codex',
1214
1284
  method: request.method,
@@ -1229,11 +1299,15 @@ export async function main() {
1229
1299
  });
1230
1300
  if (mappedApproval) {
1231
1301
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1232
- // Approval descriptor's create authors the server request (responder = ctx.ownerId).
1302
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1233
1303
  const response = await runtimeRequests.request('approval', session.conversationId, {
1234
1304
  ...mappedApproval,
1235
1305
  native: { ...mappedApproval.native, requestId, method: request.method },
1236
1306
  turnId: session.currentTurnId ?? undefined,
1307
+ ...(responseRouting.responseUserId
1308
+ ? { responseUserId: responseRouting.responseUserId }
1309
+ : {}),
1310
+ allowSessionRule: responseRouting.allowSessionRule,
1237
1311
  }, { requestId: approvalId, expiresAt });
1238
1312
  if (request.method === 'item/permissions/requestApproval') {
1239
1313
  return response.decision === 'allow'
@@ -1251,6 +1325,7 @@ export async function main() {
1251
1325
  knownConversationIds.add(input.conversationId);
1252
1326
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1253
1327
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1328
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1254
1329
  return;
1255
1330
  }
1256
1331
  if (isRecord(input.message.metadata)
@@ -1264,7 +1339,7 @@ export async function main() {
1264
1339
  : decision === 'reject'
1265
1340
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1266
1341
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1267
- enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
1342
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner, getCodexRequestingUserId(input.message));
1268
1343
  return;
1269
1344
  }
1270
1345
  let materialized = [];
@@ -1322,6 +1397,7 @@ export async function main() {
1322
1397
  : decideAutoReply(participantContext, behavior);
1323
1398
  if (!autoReply.allow) {
1324
1399
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1400
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1325
1401
  return;
1326
1402
  }
1327
1403
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1345,6 +1421,7 @@ export async function main() {
1345
1421
  replyBehavior: 'suppress_auto_reply',
1346
1422
  },
1347
1423
  }).catch(() => { });
1424
+ persistInboundRecoveryCursor(input.conversationId, input.message.id);
1348
1425
  return;
1349
1426
  }
1350
1427
  session.activeSelfContextId = activeSelfContextId;
@@ -1361,7 +1438,7 @@ export async function main() {
1361
1438
  });
1362
1439
  if (session.running && deliveryIntent === 'interrupt') {
1363
1440
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1364
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1441
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1365
1442
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1366
1443
  await session.adapter.interrupt().catch(() => { });
1367
1444
  clearStreaming(input.conversationId);
@@ -1369,7 +1446,7 @@ export async function main() {
1369
1446
  return;
1370
1447
  }
1371
1448
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1372
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1449
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1373
1450
  }
1374
1451
  function sendTurnArtifactFile(session, file) {
1375
1452
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1568,7 +1645,7 @@ export async function main() {
1568
1645
  };
1569
1646
  const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1570
1647
  planMode: nextTurn.planMode,
1571
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1648
+ onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null),
1572
1649
  });
1573
1650
  let result = await runTurnOnce();
1574
1651
  if (!result.interrupted
@@ -1585,6 +1662,10 @@ export async function main() {
1585
1662
  }
1586
1663
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1587
1664
  await routeArtifactsOnce();
1665
+ const responseRouting = buildCodexTurnResponseRouting({
1666
+ requestingUserId: nextTurn.requestingUserId,
1667
+ ownerId,
1668
+ });
1588
1669
  // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
1589
1670
  // seeds the server-owned pending node/attention/state and authors the
1590
1671
  // `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
@@ -1594,7 +1675,9 @@ export async function main() {
1594
1675
  planId: session.currentTurnId ?? randomUUID(),
1595
1676
  title: 'Codex Plan',
1596
1677
  body: result.finalMessage,
1597
- ...(ownerId ? { responseUserId: ownerId } : {}),
1678
+ ...(responseRouting.responseUserId
1679
+ ? { responseUserId: responseRouting.responseUserId }
1680
+ : {}),
1598
1681
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1599
1682
  });
1600
1683
  await handoffFinalMessage(session.conversationId);
@@ -1614,6 +1697,7 @@ export async function main() {
1614
1697
  metadata: {
1615
1698
  turnId: session.currentTurnId,
1616
1699
  turnSemantics: 'turn_complete',
1700
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1617
1701
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1618
1702
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1619
1703
  },
@@ -1638,6 +1722,7 @@ export async function main() {
1638
1722
  metadata: {
1639
1723
  turnId: session.currentTurnId,
1640
1724
  turnSemantics: 'turn_complete',
1725
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1641
1726
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1642
1727
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1643
1728
  },
@@ -1674,6 +1759,7 @@ export async function main() {
1674
1759
  metadata: {
1675
1760
  turnId: session.currentTurnId,
1676
1761
  turnSemantics: 'turn_complete',
1762
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1677
1763
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1678
1764
  },
1679
1765
  }).catch(() => { });
@@ -1684,6 +1770,11 @@ export async function main() {
1684
1770
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1685
1771
  }
1686
1772
  finally {
1773
+ persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
1774
+ if (session.pendingDroppedRecoveryCursor) {
1775
+ persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
1776
+ session.pendingDroppedRecoveryCursor = null;
1777
+ }
1687
1778
  stopVisibleWorkSignal(session);
1688
1779
  session.running = false;
1689
1780
  session.state.state = 'idle';
@@ -1702,6 +1793,30 @@ export async function main() {
1702
1793
  }
1703
1794
  }
1704
1795
  }
1796
+ const acceptedInboundMessageIds = new Set();
1797
+ const inFlightInboundMessageIds = new Set();
1798
+ function claimInboundMessageId(messageId) {
1799
+ if (!messageId)
1800
+ return true;
1801
+ if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
1802
+ return false;
1803
+ inFlightInboundMessageIds.add(messageId);
1804
+ return true;
1805
+ }
1806
+ function settleInboundMessageId(messageId, accepted) {
1807
+ if (!messageId)
1808
+ return;
1809
+ inFlightInboundMessageIds.delete(messageId);
1810
+ if (!accepted)
1811
+ return;
1812
+ acceptedInboundMessageIds.add(messageId);
1813
+ while (acceptedInboundMessageIds.size > 2_048) {
1814
+ const oldest = acceptedInboundMessageIds.values().next().value;
1815
+ if (!oldest)
1816
+ break;
1817
+ acceptedInboundMessageIds.delete(oldest);
1818
+ }
1819
+ }
1705
1820
  let streamConnected = false;
1706
1821
  const hostAvailableExecutionModes = [
1707
1822
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1821,6 +1936,7 @@ export async function main() {
1821
1936
  if (type === 'stop_and_drop') {
1822
1937
  const droppedPrompts = session.queue.splice(0);
1823
1938
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1939
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
1824
1940
  }
1825
1941
  if (session.running) {
1826
1942
  await session.adapter.interrupt();
@@ -2004,8 +2120,12 @@ export async function main() {
2004
2120
  const message = payload.message;
2005
2121
  if (message.senderId === agentId)
2006
2122
  return;
2123
+ if (!claimInboundMessageId(message.id))
2124
+ return;
2007
2125
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2008
2126
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2127
+ persistInboundRecoveryCursorWhenIdle(payload.conversationId, message.id);
2128
+ settleInboundMessageId(message.id, true);
2009
2129
  return;
2010
2130
  }
2011
2131
  void enqueueInboundMessage({
@@ -2018,14 +2138,10 @@ export async function main() {
2018
2138
  selfContexts: payload.selfContexts,
2019
2139
  provenance: payload.provenance,
2020
2140
  turnDispatch: payload.turnDispatch,
2141
+ }).then(() => settleInboundMessageId(message.id, true), (error) => {
2142
+ settleInboundMessageId(message.id, false);
2143
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
2021
2144
  });
2022
- if (message.id) {
2023
- saveRuntimeSessionState(runtimeId, {
2024
- conversationId: payload.conversationId,
2025
- baseCwd: workingDir,
2026
- lastInboundMessageId: message.id,
2027
- });
2028
- }
2029
2145
  },
2030
2146
  onMessageDeleted: (payload) => {
2031
2147
  removeQueuedPrompt(payload.conversationId, payload.messageId);
@@ -2061,6 +2177,57 @@ export async function main() {
2061
2177
  catch (error) {
2062
2178
  console.error('[canon-codex] Failed to load startup conversations:', error);
2063
2179
  }
2180
+ for (const conversationId of knownConversationIds) {
2181
+ try {
2182
+ const cursor = loadRuntimeSessionState(runtimeId, {
2183
+ conversationId,
2184
+ baseCwd: workingDir,
2185
+ })?.lastInboundMessageId ?? null;
2186
+ const recovered = await collectMissedInboundMessages({
2187
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2188
+ cursor,
2189
+ agentId,
2190
+ });
2191
+ if (recovered.mode === 'truncated-window') {
2192
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2193
+ }
2194
+ for (const message of recovered.messages) {
2195
+ const isPlanReply = message.metadata?.type === 'plan_approval_reply';
2196
+ if (!isPlanReply && !shouldTriggerAgentTurn({
2197
+ senderType: message.senderType,
2198
+ metadata: message.metadata,
2199
+ }).allow) {
2200
+ persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
2201
+ continue;
2202
+ }
2203
+ if (!claimInboundMessageId(message.id))
2204
+ continue;
2205
+ try {
2206
+ await enqueueInboundMessage({
2207
+ conversationId,
2208
+ message,
2209
+ senderName: message.senderName || message.senderId,
2210
+ isOwner: message.senderId === ownerId,
2211
+ behavior: recovered.newestPage.behavior,
2212
+ activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2213
+ selfContexts: recovered.newestPage.selfContexts,
2214
+ hydratedPage: recovered.newestPage,
2215
+ });
2216
+ settleInboundMessageId(message.id, true);
2217
+ }
2218
+ catch (error) {
2219
+ settleInboundMessageId(message.id, false);
2220
+ throw error;
2221
+ }
2222
+ }
2223
+ if (recovered.messages.length > 0) {
2224
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2225
+ }
2226
+ }
2227
+ catch (error) {
2228
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2229
+ }
2230
+ }
2064
2231
  startCodexStreamInBackground(stream, (error) => {
2065
2232
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2066
2233
  });
@@ -16,6 +16,7 @@ export interface StartupRecoveryMessage {
16
16
  id: string;
17
17
  senderId: string;
18
18
  createdAt?: string;
19
+ metadata?: Record<string, unknown>;
19
20
  }
20
21
  export interface StartupRecoveryPage {
21
22
  messages: StartupRecoveryMessage[];
@@ -37,8 +37,21 @@ export async function collectMissedInboundMessages(input) {
37
37
  cursorFound = hasCursor(fresh);
38
38
  }
39
39
  }
40
- const ascending = [...collected].sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
41
- const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId);
40
+ // The REST API already gives us a deterministic newest-first sequence,
41
+ // including messages with equal or missing timestamps. Reverse that wire
42
+ // order rather than re-sorting on a lossy display timestamp.
43
+ const ascending = [...collected].reverse();
44
+ const completedSourceMessageIds = new Set(collected.flatMap((message) => {
45
+ if (message.senderId !== input.agentId)
46
+ return [];
47
+ const metadata = message.metadata;
48
+ if (metadata?.turnSemantics !== 'turn_complete')
49
+ return [];
50
+ return typeof metadata.sourceMessageId === 'string'
51
+ ? [metadata.sourceMessageId]
52
+ : [];
53
+ }));
54
+ const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId && !completedSourceMessageIds.has(message.id));
42
55
  let mode;
43
56
  let missed;
44
57
  if (cursorFound) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,9 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^5.1.0",
32
+ "@canonmsg/agent-sdk": "^5.1.1",
33
33
  "@canonmsg/coding-agent-host": "^0.2.2",
34
- "@canonmsg/core": "^4.2.0"
34
+ "@canonmsg/core": "^4.2.2"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"