@canonmsg/codex-plugin 0.22.0 → 0.22.2

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
 
@@ -75,6 +76,7 @@ const MAX_SESSIONS = 12;
75
76
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
76
77
  const HEARTBEAT_MS = 30_000;
77
78
  const IDLE_CHECK_MS = 60_000;
79
+ const PLAN_REVIEW_TIMEOUT_MS = 10 * 60_000;
78
80
  const CODEX_RUNTIME_CAPABILITIES = {
79
81
  ...DEFAULT_RUNTIME_CAPABILITIES,
80
82
  supportsInterrupt: true,
@@ -228,6 +230,18 @@ export function buildCodexRuntimeDescriptor(input) {
228
230
  coreControls: descriptor.coreControls.filter((control) => control.id !== 'model'),
229
231
  };
230
232
  }
233
+ export function buildCodexTurnResponseRouting(input) {
234
+ const responseUserId = input.ownerOnly
235
+ ? input.ownerId ?? undefined
236
+ : input.requestingUserId ?? input.ownerId ?? undefined;
237
+ return {
238
+ ...(responseUserId ? { responseUserId } : {}),
239
+ allowSessionRule: Boolean(responseUserId && input.ownerId && responseUserId === input.ownerId),
240
+ };
241
+ }
242
+ export function getCodexRequestingUserId(message) {
243
+ return message.senderType === 'human' ? message.senderId : null;
244
+ }
231
245
  function modelOptionLabel(model) {
232
246
  if (/^gpt-5\.5(?:$|[-_:])/i.test(model))
233
247
  return 'GPT-5.5';
@@ -534,7 +548,10 @@ export async function main() {
534
548
  kind: 'approval',
535
549
  generateId: () => randomUUID(),
536
550
  create: async ({ client: apiClient, ctx, conversationId, requestId, payload, expiresAt }) => {
537
- await apiClient.createRuntimeApprovalRequest({
551
+ const responseUserId = (payload.responseUserId ?? ctx.ownerId) || undefined;
552
+ const allowSessionRule = payload.allowSessionRule !== false
553
+ && Boolean(responseUserId && responseUserId === ctx.ownerId);
554
+ const created = await apiClient.createRuntimeApprovalRequest({
538
555
  conversationId,
539
556
  approvalId: requestId,
540
557
  toolName: payload.toolName,
@@ -545,14 +562,17 @@ export async function main() {
545
562
  native: payload.native,
546
563
  details: payload.details,
547
564
  ...(payload.diff ? { diff: payload.diff } : {}),
548
- responseUserId: ctx.ownerId || undefined,
549
- allowSessionRule: true,
565
+ ...(responseUserId ? { responseUserId } : {}),
566
+ allowSessionRule,
550
567
  expiresAt,
551
568
  ...(payload.turnId ? { turnId: payload.turnId } : {}),
552
569
  });
553
- return { requestId };
570
+ return {
571
+ requestId,
572
+ state: { allowSessionRule: created.allowSessionRule ?? allowSessionRule },
573
+ };
554
574
  },
555
- consume: async ({ client: apiClient, conversationId, requestId }) => {
575
+ consume: async ({ client: apiClient, conversationId, requestId, state }) => {
556
576
  const response = await apiClient
557
577
  .consumeRuntimeApprovalResponse({ conversationId, approvalId: requestId })
558
578
  .catch(() => null);
@@ -561,7 +581,9 @@ export async function main() {
561
581
  state: 'resolved',
562
582
  result: {
563
583
  decision: 'allow',
564
- ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
584
+ ...(state?.allowSessionRule === true && response.sessionRule
585
+ ? { sessionRule: response.sessionRule }
586
+ : {}),
565
587
  },
566
588
  };
567
589
  }
@@ -609,6 +631,7 @@ export async function main() {
609
631
  });
610
632
  const sessions = new Map();
611
633
  const pendingSessionCreations = new Map();
634
+ let inboundRecoverySequence = 0;
612
635
  const conversationCache = new Map();
613
636
  const knownConversationIds = new Set();
614
637
  const promptedGroupContextConversationIds = new Set();
@@ -754,6 +777,29 @@ export async function main() {
754
777
  return;
755
778
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
756
779
  }
780
+ function persistInboundRecoveryCursor(conversationId, messageId) {
781
+ if (!messageId)
782
+ return;
783
+ try {
784
+ saveRuntimeSessionState(runtimeId, {
785
+ conversationId,
786
+ baseCwd: workingDir,
787
+ lastInboundMessageId: messageId,
788
+ });
789
+ }
790
+ catch (error) {
791
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
792
+ }
793
+ }
794
+ function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
795
+ const session = sessions.get(conversationId);
796
+ // A later queued turn will advance past this non-triggering message. Do
797
+ // not write it ahead of earlier work and then let that work regress the
798
+ // cursor when it completes.
799
+ if (session?.running || (session?.queue.length ?? 0) > 0)
800
+ return;
801
+ persistInboundRecoveryCursor(conversationId, messageId);
802
+ }
757
803
  async function markQueuedPromptsRejected(conversationId, prompts) {
758
804
  await Promise.all(prompts.map((prompt) => {
759
805
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -761,6 +807,18 @@ export async function main() {
761
807
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
762
808
  }));
763
809
  }
810
+ function rememberDroppedRecoveryCursor(session, prompts) {
811
+ const latest = prompts.reduce((current, prompt) => (prompt.sourceMessageId && (!current || prompt.recoverySequence > current.recoverySequence)
812
+ ? prompt
813
+ : current), null)?.sourceMessageId;
814
+ if (!latest)
815
+ return;
816
+ if (session.running) {
817
+ session.pendingDroppedRecoveryCursor = latest;
818
+ return;
819
+ }
820
+ persistInboundRecoveryCursor(session.conversationId, latest);
821
+ }
764
822
  function removeQueuedPrompt(conversationId, sourceMessageId) {
765
823
  const session = sessions.get(conversationId);
766
824
  if (!session || session.queue.length === 0)
@@ -888,6 +946,7 @@ export async function main() {
888
946
  session.resetRequested = true;
889
947
  const droppedPrompts = session.queue.splice(0);
890
948
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
949
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
891
950
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
892
951
  session.adapter.clearThreadId();
893
952
  session.activeSelfContextId = null;
@@ -1012,6 +1071,7 @@ export async function main() {
1012
1071
  currentTurnCanUseCodexAppTools: false,
1013
1072
  activeSelfContextId: null,
1014
1073
  lastAcceptedIntent: null,
1074
+ pendingDroppedRecoveryCursor: null,
1015
1075
  resetRequested: false,
1016
1076
  lastActivity: Date.now(),
1017
1077
  typingKeepaliveTimer: null,
@@ -1040,7 +1100,7 @@ export async function main() {
1040
1100
  pendingSessionCreations.delete(conversationId);
1041
1101
  }
1042
1102
  }
1043
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
1103
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false, requestingUserId = null) {
1044
1104
  const nextPrompt = {
1045
1105
  prompt,
1046
1106
  intent,
@@ -1051,6 +1111,8 @@ export async function main() {
1051
1111
  planMode,
1052
1112
  artifactRoutingMode,
1053
1113
  canUseCodexAppTools,
1114
+ requestingUserId,
1115
+ recoverySequence: ++inboundRecoverySequence,
1054
1116
  };
1055
1117
  if (toFront) {
1056
1118
  session.queue.unshift(nextPrompt);
@@ -1062,6 +1124,19 @@ export async function main() {
1062
1124
  writeTurn(session);
1063
1125
  void runNextTurn(session);
1064
1126
  }
1127
+ function enqueueCodexPlanReviewResult(session, result, responseUserId) {
1128
+ if (result.status === 'cancelled' || result.status === 'timeout') {
1129
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Plan review ${result.status}`);
1130
+ return;
1131
+ }
1132
+ const feedback = result.feedback?.trim();
1133
+ const prompt = result.status === 'approve'
1134
+ ? 'The plan was approved. Implement the approved plan now.'
1135
+ : result.status === 'reject'
1136
+ ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1137
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1138
+ enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', 'disabled', false, responseUserId);
1139
+ }
1065
1140
  function resolveArtifactRoutingMode(participantContext) {
1066
1141
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1067
1142
  ? 'workspace-generated'
@@ -1076,7 +1151,7 @@ export async function main() {
1076
1151
  const args = isRecord(params.arguments) ? params.arguments : null;
1077
1152
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1078
1153
  }
1079
- async function handleCodexServerRequest(session, request) {
1154
+ async function handleCodexServerRequest(session, request, requestingUserId) {
1080
1155
  const requestId = String(request.id);
1081
1156
  const params = request.params;
1082
1157
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1108,11 +1183,10 @@ export async function main() {
1108
1183
  ?? requestId;
1109
1184
  let requestCreated = false;
1110
1185
  let requestResolved = false;
1186
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1111
1187
  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).
1188
+ // Built-in card descriptor owns create+poll; Codex passes the native
1189
+ // handles, turn context, and the human responder captured for this turn.
1116
1190
  const cardResult = await runtimeRequests.request('card', session.conversationId, {
1117
1191
  card,
1118
1192
  native: {
@@ -1126,6 +1200,9 @@ export async function main() {
1126
1200
  },
1127
1201
  },
1128
1202
  turnId: session.currentTurnId ?? undefined,
1203
+ ...(responseRouting.responseUserId
1204
+ ? { responseUserId: responseRouting.responseUserId }
1205
+ : {}),
1129
1206
  }, {
1130
1207
  requestId: cardId,
1131
1208
  expiresAt,
@@ -1199,8 +1276,12 @@ export async function main() {
1199
1276
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1200
1277
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1201
1278
  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).
1279
+ const sensitive = Boolean(questions?.some((question) => question.isSecret));
1280
+ const responseRouting = buildCodexTurnResponseRouting({
1281
+ requestingUserId,
1282
+ ownerId,
1283
+ ownerOnly: sensitive,
1284
+ });
1204
1285
  const response = await runtimeRequests.request('input', session.conversationId, {
1205
1286
  kind: 'clarify',
1206
1287
  title: 'Codex needs input',
@@ -1208,7 +1289,10 @@ export async function main() {
1208
1289
  ? 'Codex needs your input to continue.'
1209
1290
  : 'Codex needs input.',
1210
1291
  ...(questions ? { questions } : {}),
1211
- sensitive: Boolean(questions?.some((question) => question.isSecret)),
1292
+ sensitive,
1293
+ ...(responseRouting.responseUserId
1294
+ ? { responseUserId: responseRouting.responseUserId }
1295
+ : {}),
1212
1296
  native: {
1213
1297
  runtime: 'codex',
1214
1298
  method: request.method,
@@ -1229,11 +1313,15 @@ export async function main() {
1229
1313
  });
1230
1314
  if (mappedApproval) {
1231
1315
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1232
- // Approval descriptor's create authors the server request (responder = ctx.ownerId).
1316
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1233
1317
  const response = await runtimeRequests.request('approval', session.conversationId, {
1234
1318
  ...mappedApproval,
1235
1319
  native: { ...mappedApproval.native, requestId, method: request.method },
1236
1320
  turnId: session.currentTurnId ?? undefined,
1321
+ ...(responseRouting.responseUserId
1322
+ ? { responseUserId: responseRouting.responseUserId }
1323
+ : {}),
1324
+ allowSessionRule: responseRouting.allowSessionRule,
1237
1325
  }, { requestId: approvalId, expiresAt });
1238
1326
  if (request.method === 'item/permissions/requestApproval') {
1239
1327
  return response.decision === 'allow'
@@ -1251,11 +1339,20 @@ export async function main() {
1251
1339
  knownConversationIds.add(input.conversationId);
1252
1340
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1253
1341
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1342
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1254
1343
  return;
1255
1344
  }
1256
1345
  if (isRecord(input.message.metadata)
1257
1346
  && input.message.metadata.type === 'plan_approval_reply'
1258
1347
  && typeof input.message.metadata.decision === 'string') {
1348
+ const planId = readString(input.message.metadata, 'planId');
1349
+ if (planId && runtimeRequests.handleMessage(input.conversationId, {
1350
+ senderId: input.message.senderId,
1351
+ metadata: input.message.metadata,
1352
+ })) {
1353
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1354
+ return;
1355
+ }
1259
1356
  const session = await getOrCreateSession(input.conversationId);
1260
1357
  const feedback = readString(input.message.metadata, 'feedback');
1261
1358
  const decision = input.message.metadata.decision;
@@ -1264,7 +1361,7 @@ export async function main() {
1264
1361
  : decision === 'reject'
1265
1362
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1266
1363
  : `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);
1364
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner, getCodexRequestingUserId(input.message));
1268
1365
  return;
1269
1366
  }
1270
1367
  let materialized = [];
@@ -1322,6 +1419,7 @@ export async function main() {
1322
1419
  : decideAutoReply(participantContext, behavior);
1323
1420
  if (!autoReply.allow) {
1324
1421
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1422
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1325
1423
  return;
1326
1424
  }
1327
1425
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1345,6 +1443,7 @@ export async function main() {
1345
1443
  replyBehavior: 'suppress_auto_reply',
1346
1444
  },
1347
1445
  }).catch(() => { });
1446
+ persistInboundRecoveryCursor(input.conversationId, input.message.id);
1348
1447
  return;
1349
1448
  }
1350
1449
  session.activeSelfContextId = activeSelfContextId;
@@ -1361,7 +1460,7 @@ export async function main() {
1361
1460
  });
1362
1461
  if (session.running && deliveryIntent === 'interrupt') {
1363
1462
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1364
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1463
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1365
1464
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1366
1465
  await session.adapter.interrupt().catch(() => { });
1367
1466
  clearStreaming(input.conversationId);
@@ -1369,7 +1468,7 @@ export async function main() {
1369
1468
  return;
1370
1469
  }
1371
1470
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1372
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1471
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1373
1472
  }
1374
1473
  function sendTurnArtifactFile(session, file) {
1375
1474
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1568,7 +1667,7 @@ export async function main() {
1568
1667
  };
1569
1668
  const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1570
1669
  planMode: nextTurn.planMode,
1571
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1670
+ onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null),
1572
1671
  });
1573
1672
  let result = await runTurnOnce();
1574
1673
  if (!result.interrupted
@@ -1585,18 +1684,47 @@ export async function main() {
1585
1684
  }
1586
1685
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1587
1686
  await routeArtifactsOnce();
1588
- // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
1589
- // seeds the server-owned pending node/attention/state and authors the
1590
- // `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
1591
- // fresh turn off the server-authored `plan_approval_reply`.
1592
- await client.createRuntimePlanRequest({
1593
- conversationId: session.conversationId,
1594
- planId: session.currentTurnId ?? randomUUID(),
1687
+ const responseRouting = buildCodexTurnResponseRouting({
1688
+ requestingUserId: nextTurn.requestingUserId,
1689
+ ownerId,
1690
+ });
1691
+ const planId = session.currentTurnId ?? randomUUID();
1692
+ let planCreated = false;
1693
+ let resolvePlanCreated;
1694
+ let rejectPlanCreated;
1695
+ const planCreatedPromise = new Promise((resolve, reject) => {
1696
+ resolvePlanCreated = resolve;
1697
+ rejectPlanCreated = reject;
1698
+ });
1699
+ const planReview = runtimeRequests.request('plan', session.conversationId, {
1595
1700
  title: 'Codex Plan',
1596
1701
  body: result.finalMessage,
1597
- ...(ownerId ? { responseUserId: ownerId } : {}),
1702
+ ...(responseRouting.responseUserId
1703
+ ? { responseUserId: responseRouting.responseUserId }
1704
+ : {}),
1598
1705
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1706
+ }, {
1707
+ requestId: planId,
1708
+ expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS,
1709
+ responderPolicy: 'infer',
1710
+ onCreated: () => {
1711
+ planCreated = true;
1712
+ session.turnState = 'waiting_input';
1713
+ writeTurn(session);
1714
+ resolvePlanCreated();
1715
+ },
1599
1716
  });
1717
+ void planReview.then((planResult) => {
1718
+ resolvePlanCreated();
1719
+ enqueueCodexPlanReviewResult(session, planResult, responseRouting.responseUserId ?? null);
1720
+ }).catch((error) => {
1721
+ if (!planCreated) {
1722
+ rejectPlanCreated(error);
1723
+ return;
1724
+ }
1725
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Plan review failed:`, error instanceof Error ? error.message : error);
1726
+ });
1727
+ await planCreatedPromise;
1600
1728
  await handoffFinalMessage(session.conversationId);
1601
1729
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1602
1730
  }
@@ -1614,6 +1742,7 @@ export async function main() {
1614
1742
  metadata: {
1615
1743
  turnId: session.currentTurnId,
1616
1744
  turnSemantics: 'turn_complete',
1745
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1617
1746
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1618
1747
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1619
1748
  },
@@ -1638,6 +1767,7 @@ export async function main() {
1638
1767
  metadata: {
1639
1768
  turnId: session.currentTurnId,
1640
1769
  turnSemantics: 'turn_complete',
1770
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1641
1771
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1642
1772
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1643
1773
  },
@@ -1674,6 +1804,7 @@ export async function main() {
1674
1804
  metadata: {
1675
1805
  turnId: session.currentTurnId,
1676
1806
  turnSemantics: 'turn_complete',
1807
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1677
1808
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1678
1809
  },
1679
1810
  }).catch(() => { });
@@ -1684,6 +1815,11 @@ export async function main() {
1684
1815
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1685
1816
  }
1686
1817
  finally {
1818
+ persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
1819
+ if (session.pendingDroppedRecoveryCursor) {
1820
+ persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
1821
+ session.pendingDroppedRecoveryCursor = null;
1822
+ }
1687
1823
  stopVisibleWorkSignal(session);
1688
1824
  session.running = false;
1689
1825
  session.state.state = 'idle';
@@ -1702,6 +1838,30 @@ export async function main() {
1702
1838
  }
1703
1839
  }
1704
1840
  }
1841
+ const acceptedInboundMessageIds = new Set();
1842
+ const inFlightInboundMessageIds = new Set();
1843
+ function claimInboundMessageId(messageId) {
1844
+ if (!messageId)
1845
+ return true;
1846
+ if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
1847
+ return false;
1848
+ inFlightInboundMessageIds.add(messageId);
1849
+ return true;
1850
+ }
1851
+ function settleInboundMessageId(messageId, accepted) {
1852
+ if (!messageId)
1853
+ return;
1854
+ inFlightInboundMessageIds.delete(messageId);
1855
+ if (!accepted)
1856
+ return;
1857
+ acceptedInboundMessageIds.add(messageId);
1858
+ while (acceptedInboundMessageIds.size > 2_048) {
1859
+ const oldest = acceptedInboundMessageIds.values().next().value;
1860
+ if (!oldest)
1861
+ break;
1862
+ acceptedInboundMessageIds.delete(oldest);
1863
+ }
1864
+ }
1705
1865
  let streamConnected = false;
1706
1866
  const hostAvailableExecutionModes = [
1707
1867
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1821,6 +1981,7 @@ export async function main() {
1821
1981
  if (type === 'stop_and_drop') {
1822
1982
  const droppedPrompts = session.queue.splice(0);
1823
1983
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1984
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
1824
1985
  }
1825
1986
  if (session.running) {
1826
1987
  await session.adapter.interrupt();
@@ -2004,8 +2165,12 @@ export async function main() {
2004
2165
  const message = payload.message;
2005
2166
  if (message.senderId === agentId)
2006
2167
  return;
2168
+ if (!claimInboundMessageId(message.id))
2169
+ return;
2007
2170
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2008
2171
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2172
+ persistInboundRecoveryCursorWhenIdle(payload.conversationId, message.id);
2173
+ settleInboundMessageId(message.id, true);
2009
2174
  return;
2010
2175
  }
2011
2176
  void enqueueInboundMessage({
@@ -2018,14 +2183,10 @@ export async function main() {
2018
2183
  selfContexts: payload.selfContexts,
2019
2184
  provenance: payload.provenance,
2020
2185
  turnDispatch: payload.turnDispatch,
2186
+ }).then(() => settleInboundMessageId(message.id, true), (error) => {
2187
+ settleInboundMessageId(message.id, false);
2188
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
2021
2189
  });
2022
- if (message.id) {
2023
- saveRuntimeSessionState(runtimeId, {
2024
- conversationId: payload.conversationId,
2025
- baseCwd: workingDir,
2026
- lastInboundMessageId: message.id,
2027
- });
2028
- }
2029
2190
  },
2030
2191
  onMessageDeleted: (payload) => {
2031
2192
  removeQueuedPrompt(payload.conversationId, payload.messageId);
@@ -2061,6 +2222,57 @@ export async function main() {
2061
2222
  catch (error) {
2062
2223
  console.error('[canon-codex] Failed to load startup conversations:', error);
2063
2224
  }
2225
+ for (const conversationId of knownConversationIds) {
2226
+ try {
2227
+ const cursor = loadRuntimeSessionState(runtimeId, {
2228
+ conversationId,
2229
+ baseCwd: workingDir,
2230
+ })?.lastInboundMessageId ?? null;
2231
+ const recovered = await collectMissedInboundMessages({
2232
+ fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2233
+ cursor,
2234
+ agentId,
2235
+ });
2236
+ if (recovered.mode === 'truncated-window') {
2237
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
2238
+ }
2239
+ for (const message of recovered.messages) {
2240
+ const isPlanReply = message.metadata?.type === 'plan_approval_reply';
2241
+ if (!isPlanReply && !shouldTriggerAgentTurn({
2242
+ senderType: message.senderType,
2243
+ metadata: message.metadata,
2244
+ }).allow) {
2245
+ persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
2246
+ continue;
2247
+ }
2248
+ if (!claimInboundMessageId(message.id))
2249
+ continue;
2250
+ try {
2251
+ await enqueueInboundMessage({
2252
+ conversationId,
2253
+ message,
2254
+ senderName: message.senderName || message.senderId,
2255
+ isOwner: message.senderId === ownerId,
2256
+ behavior: recovered.newestPage.behavior,
2257
+ activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2258
+ selfContexts: recovered.newestPage.selfContexts,
2259
+ hydratedPage: recovered.newestPage,
2260
+ });
2261
+ settleInboundMessageId(message.id, true);
2262
+ }
2263
+ catch (error) {
2264
+ settleInboundMessageId(message.id, false);
2265
+ throw error;
2266
+ }
2267
+ }
2268
+ if (recovered.messages.length > 0) {
2269
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2270
+ }
2271
+ }
2272
+ catch (error) {
2273
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
2274
+ }
2275
+ }
2064
2276
  startCodexStreamInBackground(stream, (error) => {
2065
2277
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2066
2278
  });
@@ -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.2",
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.3"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"