@canonmsg/codex-plugin 0.21.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, buildPlanApprovalRequest, 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, 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';
@@ -522,6 +535,66 @@ export async function main() {
522
535
  }
523
536
  console.error(`[canon-codex] Authenticated as ${agentId}`);
524
537
  }
538
+ // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
539
+ // create+poll (codex passes native/responder policy via payload/options).
540
+ // Approval keeps codex's own resolution shape (no owner-authored outcome
541
+ // messages / session rules — that is the SDK ApprovalManager's job).
542
+ const runtimeRequests = new RuntimeRequestManager(client, {
543
+ agentId,
544
+ ownerId: ownerId ?? '',
545
+ });
546
+ runtimeRequests.register('approval', {
547
+ kind: 'approval',
548
+ generateId: () => randomUUID(),
549
+ create: async ({ client: apiClient, ctx, conversationId, requestId, payload, expiresAt }) => {
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({
554
+ conversationId,
555
+ approvalId: requestId,
556
+ toolName: payload.toolName,
557
+ toolSummary: payload.toolSummary,
558
+ category: payload.category,
559
+ risk: payload.risk,
560
+ riskLevel: payload.riskLevel,
561
+ native: payload.native,
562
+ details: payload.details,
563
+ ...(payload.diff ? { diff: payload.diff } : {}),
564
+ ...(responseUserId ? { responseUserId } : {}),
565
+ allowSessionRule,
566
+ expiresAt,
567
+ ...(payload.turnId ? { turnId: payload.turnId } : {}),
568
+ });
569
+ return {
570
+ requestId,
571
+ state: { allowSessionRule: created.allowSessionRule ?? allowSessionRule },
572
+ };
573
+ },
574
+ consume: async ({ client: apiClient, conversationId, requestId, state }) => {
575
+ const response = await apiClient
576
+ .consumeRuntimeApprovalResponse({ conversationId, approvalId: requestId })
577
+ .catch(() => null);
578
+ if (response?.status === 'allow') {
579
+ return {
580
+ state: 'resolved',
581
+ result: {
582
+ decision: 'allow',
583
+ ...(state?.allowSessionRule === true && response.sessionRule
584
+ ? { sessionRule: response.sessionRule }
585
+ : {}),
586
+ },
587
+ };
588
+ }
589
+ if (response?.status === 'deny') {
590
+ return { state: 'resolved', result: { decision: 'deny' } };
591
+ }
592
+ if (response?.status === 'timeout')
593
+ return { state: 'timeout' };
594
+ return { state: 'pending' };
595
+ },
596
+ timeoutResult: () => ({ decision: 'deny' }),
597
+ });
525
598
  const launchArgs = [...process.argv.slice(2)];
526
599
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
527
600
  launchArgs.push('--cwd', workingDir);
@@ -557,6 +630,7 @@ export async function main() {
557
630
  });
558
631
  const sessions = new Map();
559
632
  const pendingSessionCreations = new Map();
633
+ let inboundRecoverySequence = 0;
560
634
  const conversationCache = new Map();
561
635
  const knownConversationIds = new Set();
562
636
  const promptedGroupContextConversationIds = new Set();
@@ -702,6 +776,29 @@ export async function main() {
702
776
  return;
703
777
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
704
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
+ }
705
802
  async function markQueuedPromptsRejected(conversationId, prompts) {
706
803
  await Promise.all(prompts.map((prompt) => {
707
804
  if (!prompt.markAccepted || !prompt.sourceMessageId)
@@ -709,6 +806,18 @@ export async function main() {
709
806
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
710
807
  }));
711
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
+ }
712
821
  function removeQueuedPrompt(conversationId, sourceMessageId) {
713
822
  const session = sessions.get(conversationId);
714
823
  if (!session || session.queue.length === 0)
@@ -836,6 +945,7 @@ export async function main() {
836
945
  session.resetRequested = true;
837
946
  const droppedPrompts = session.queue.splice(0);
838
947
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
948
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
839
949
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
840
950
  session.adapter.clearThreadId();
841
951
  session.activeSelfContextId = null;
@@ -960,6 +1070,7 @@ export async function main() {
960
1070
  currentTurnCanUseCodexAppTools: false,
961
1071
  activeSelfContextId: null,
962
1072
  lastAcceptedIntent: null,
1073
+ pendingDroppedRecoveryCursor: null,
963
1074
  resetRequested: false,
964
1075
  lastActivity: Date.now(),
965
1076
  typingKeepaliveTimer: null,
@@ -988,7 +1099,7 @@ export async function main() {
988
1099
  pendingSessionCreations.delete(conversationId);
989
1100
  }
990
1101
  }
991
- 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) {
992
1103
  const nextPrompt = {
993
1104
  prompt,
994
1105
  intent,
@@ -999,6 +1110,8 @@ export async function main() {
999
1110
  planMode,
1000
1111
  artifactRoutingMode,
1001
1112
  canUseCodexAppTools,
1113
+ requestingUserId,
1114
+ recoverySequence: ++inboundRecoverySequence,
1002
1115
  };
1003
1116
  if (toFront) {
1004
1117
  session.queue.unshift(nextPrompt);
@@ -1015,80 +1128,6 @@ export async function main() {
1015
1128
  ? 'workspace-generated'
1016
1129
  : 'disabled';
1017
1130
  }
1018
- async function waitForRuntimeInputResponse(input) {
1019
- while (Date.now() < input.expiresAt) {
1020
- const response = await client.consumeRuntimeInputResponse({
1021
- conversationId: input.conversationId,
1022
- inputId: input.inputId,
1023
- }).catch(() => null);
1024
- if (response?.status === 'submitted') {
1025
- return { status: 'submitted', value: response.value, answers: response.answers };
1026
- }
1027
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1028
- return { status: response.status };
1029
- }
1030
- await sleep(1_000);
1031
- }
1032
- const response = await client.consumeRuntimeInputResponse({
1033
- conversationId: input.conversationId,
1034
- inputId: input.inputId,
1035
- }).catch(() => null);
1036
- if (response?.status === 'submitted') {
1037
- return { status: 'submitted', value: response.value, answers: response.answers };
1038
- }
1039
- return { status: 'timeout' };
1040
- }
1041
- async function waitForRuntimeApprovalResponse(input) {
1042
- while (Date.now() < input.expiresAt) {
1043
- const response = await client.consumeRuntimeApprovalResponse({
1044
- conversationId: input.conversationId,
1045
- approvalId: input.approvalId,
1046
- }).catch(() => null);
1047
- if (response?.status === 'allow') {
1048
- return { decision: 'allow', sessionRule: response.sessionRule };
1049
- }
1050
- if (response?.status === 'deny' || response?.status === 'timeout') {
1051
- return { decision: 'deny' };
1052
- }
1053
- await sleep(1_000);
1054
- }
1055
- await client.consumeRuntimeApprovalResponse({
1056
- conversationId: input.conversationId,
1057
- approvalId: input.approvalId,
1058
- }).catch(() => null);
1059
- return { decision: 'deny' };
1060
- }
1061
- async function waitForRuntimeCardResponse(input) {
1062
- while (Date.now() < input.expiresAt) {
1063
- const response = await client.consumeRuntimeCardResponse({
1064
- conversationId: input.conversationId,
1065
- cardId: input.cardId,
1066
- }).catch(() => null);
1067
- if (response?.status === 'submitted') {
1068
- return {
1069
- status: 'submitted',
1070
- ...(response.actionId ? { actionId: response.actionId } : {}),
1071
- ...(response.values ? { values: response.values } : {}),
1072
- };
1073
- }
1074
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1075
- return { status: response.status };
1076
- }
1077
- await sleep(1_000);
1078
- }
1079
- const response = await client.consumeRuntimeCardResponse({
1080
- conversationId: input.conversationId,
1081
- cardId: input.cardId,
1082
- }).catch(() => null);
1083
- if (response?.status === 'submitted') {
1084
- return {
1085
- status: 'submitted',
1086
- ...(response.actionId ? { actionId: response.actionId } : {}),
1087
- ...(response.values ? { values: response.values } : {}),
1088
- };
1089
- }
1090
- return { status: 'timeout' };
1091
- }
1092
1131
  function runtimeCardRequestPayload(method, params) {
1093
1132
  if (method !== 'item/runtimeCard/request'
1094
1133
  && method !== 'runtimeCard/request') {
@@ -1098,7 +1137,7 @@ export async function main() {
1098
1137
  const args = isRecord(params.arguments) ? params.arguments : null;
1099
1138
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1100
1139
  }
1101
- async function handleCodexServerRequest(session, request) {
1140
+ async function handleCodexServerRequest(session, request, requestingUserId) {
1102
1141
  const requestId = String(request.id);
1103
1142
  const params = request.params;
1104
1143
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1130,15 +1169,12 @@ export async function main() {
1130
1169
  ?? requestId;
1131
1170
  let requestCreated = false;
1132
1171
  let requestResolved = false;
1172
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1133
1173
  try {
1134
- await client.createRuntimeCardRequest({
1135
- conversationId: session.conversationId,
1136
- cardId,
1137
- card: { ...card, cardId },
1138
- expiresAt,
1139
- // Omit responseUserId so the backend targets a reachable member (the
1140
- // owner if present, else the sole other member) — the owner is often
1141
- // 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.
1176
+ const cardResult = await runtimeRequests.request('card', session.conversationId, {
1177
+ card,
1142
1178
  native: {
1143
1179
  runtime: 'codex',
1144
1180
  method: request.method,
@@ -1150,25 +1186,35 @@ export async function main() {
1150
1186
  },
1151
1187
  },
1152
1188
  turnId: session.currentTurnId ?? undefined,
1153
- });
1154
- requestCreated = true;
1155
- session.turnState = 'waiting_input';
1156
- markTurnProgress(session);
1157
- upsertTurnBlock(session, {
1158
- id: `card:${cardId}`,
1159
- kind: 'input',
1160
- status: 'pending',
1161
- title: card.title,
1162
- summary: card.template ?? 'runtime card',
1163
- });
1164
- writeTurn(session);
1165
- stopVisibleWorkSignal(session);
1166
- writeCodexStreaming(session, null, 'waiting_input');
1167
- const response = await waitForRuntimeCardResponse({
1168
- conversationId: session.conversationId,
1169
- cardId,
1189
+ ...(responseRouting.responseUserId
1190
+ ? { responseUserId: responseRouting.responseUserId }
1191
+ : {}),
1192
+ }, {
1193
+ requestId: cardId,
1170
1194
  expiresAt,
1195
+ onCreated: () => {
1196
+ requestCreated = true;
1197
+ session.turnState = 'waiting_input';
1198
+ markTurnProgress(session);
1199
+ upsertTurnBlock(session, {
1200
+ id: `card:${cardId}`,
1201
+ kind: 'input',
1202
+ status: 'pending',
1203
+ title: card.title,
1204
+ summary: card.template ?? 'runtime card',
1205
+ });
1206
+ writeTurn(session);
1207
+ stopVisibleWorkSignal(session);
1208
+ writeCodexStreaming(session, null, 'waiting_input');
1209
+ },
1171
1210
  });
1211
+ const response = cardResult.status === 'submitted'
1212
+ ? {
1213
+ status: 'submitted',
1214
+ ...(cardResult.actionId ? { actionId: cardResult.actionId } : {}),
1215
+ ...(cardResult.values ? { values: cardResult.values } : {}),
1216
+ }
1217
+ : { status: cardResult.status };
1172
1218
  requestResolved = true;
1173
1219
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1174
1220
  await sendMessageWithRetry(client, session.conversationId, outcome.text, {
@@ -1216,18 +1262,23 @@ export async function main() {
1216
1262
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1217
1263
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1218
1264
  const inputId = readString(params, 'itemId') ?? requestId;
1219
- await client.createRuntimeInputRequest({
1220
- conversationId: session.conversationId,
1221
- inputId,
1265
+ const sensitive = Boolean(questions?.some((question) => question.isSecret));
1266
+ const responseRouting = buildCodexTurnResponseRouting({
1267
+ requestingUserId,
1268
+ ownerId,
1269
+ ownerOnly: sensitive,
1270
+ });
1271
+ const response = await runtimeRequests.request('input', session.conversationId, {
1222
1272
  kind: 'clarify',
1223
- expiresAt,
1224
- responseUserId: ownerId ?? undefined,
1225
1273
  title: 'Codex needs input',
1226
1274
  prompt: questions?.length
1227
1275
  ? 'Codex needs your input to continue.'
1228
1276
  : 'Codex needs input.',
1229
1277
  ...(questions ? { questions } : {}),
1230
- sensitive: Boolean(questions?.some((question) => question.isSecret)),
1278
+ sensitive,
1279
+ ...(responseRouting.responseUserId
1280
+ ? { responseUserId: responseRouting.responseUserId }
1281
+ : {}),
1231
1282
  native: {
1232
1283
  runtime: 'codex',
1233
1284
  method: request.method,
@@ -1239,12 +1290,7 @@ export async function main() {
1239
1290
  },
1240
1291
  },
1241
1292
  turnId: session.currentTurnId ?? undefined,
1242
- });
1243
- const response = await waitForRuntimeInputResponse({
1244
- conversationId: session.conversationId,
1245
- inputId,
1246
- expiresAt,
1247
- });
1293
+ }, { requestId: inputId, expiresAt });
1248
1294
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1249
1295
  }
1250
1296
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1253,31 +1299,16 @@ export async function main() {
1253
1299
  });
1254
1300
  if (mappedApproval) {
1255
1301
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1256
- await client.createRuntimeApprovalRequest({
1257
- conversationId: session.conversationId,
1258
- approvalId,
1259
- toolName: mappedApproval.toolName,
1260
- toolSummary: mappedApproval.toolSummary,
1261
- category: mappedApproval.category,
1262
- risk: mappedApproval.risk,
1263
- riskLevel: mappedApproval.riskLevel,
1264
- native: {
1265
- ...mappedApproval.native,
1266
- requestId,
1267
- method: request.method,
1268
- },
1269
- details: mappedApproval.details,
1270
- ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1271
- responseUserId: ownerId ?? undefined,
1272
- allowSessionRule: true,
1273
- expiresAt,
1302
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1303
+ const response = await runtimeRequests.request('approval', session.conversationId, {
1304
+ ...mappedApproval,
1305
+ native: { ...mappedApproval.native, requestId, method: request.method },
1274
1306
  turnId: session.currentTurnId ?? undefined,
1275
- });
1276
- const response = await waitForRuntimeApprovalResponse({
1277
- conversationId: session.conversationId,
1278
- approvalId,
1279
- expiresAt,
1280
- });
1307
+ ...(responseRouting.responseUserId
1308
+ ? { responseUserId: responseRouting.responseUserId }
1309
+ : {}),
1310
+ allowSessionRule: responseRouting.allowSessionRule,
1311
+ }, { requestId: approvalId, expiresAt });
1281
1312
  if (request.method === 'item/permissions/requestApproval') {
1282
1313
  return response.decision === 'allow'
1283
1314
  ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
@@ -1294,6 +1325,7 @@ export async function main() {
1294
1325
  knownConversationIds.add(input.conversationId);
1295
1326
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1296
1327
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1328
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1297
1329
  return;
1298
1330
  }
1299
1331
  if (isRecord(input.message.metadata)
@@ -1307,7 +1339,7 @@ export async function main() {
1307
1339
  : decision === 'reject'
1308
1340
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1309
1341
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1310
- 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));
1311
1343
  return;
1312
1344
  }
1313
1345
  let materialized = [];
@@ -1365,6 +1397,7 @@ export async function main() {
1365
1397
  : decideAutoReply(participantContext, behavior);
1366
1398
  if (!autoReply.allow) {
1367
1399
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1400
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1368
1401
  return;
1369
1402
  }
1370
1403
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1388,6 +1421,7 @@ export async function main() {
1388
1421
  replyBehavior: 'suppress_auto_reply',
1389
1422
  },
1390
1423
  }).catch(() => { });
1424
+ persistInboundRecoveryCursor(input.conversationId, input.message.id);
1391
1425
  return;
1392
1426
  }
1393
1427
  session.activeSelfContextId = activeSelfContextId;
@@ -1404,7 +1438,7 @@ export async function main() {
1404
1438
  });
1405
1439
  if (session.running && deliveryIntent === 'interrupt') {
1406
1440
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1407
- 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));
1408
1442
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1409
1443
  await session.adapter.interrupt().catch(() => { });
1410
1444
  clearStreaming(input.conversationId);
@@ -1412,7 +1446,7 @@ export async function main() {
1412
1446
  return;
1413
1447
  }
1414
1448
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1415
- 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));
1416
1450
  }
1417
1451
  function sendTurnArtifactFile(session, file) {
1418
1452
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1611,7 +1645,7 @@ export async function main() {
1611
1645
  };
1612
1646
  const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1613
1647
  planMode: nextTurn.planMode,
1614
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1648
+ onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null),
1615
1649
  });
1616
1650
  let result = await runTurnOnce();
1617
1651
  if (!result.interrupted
@@ -1628,19 +1662,23 @@ export async function main() {
1628
1662
  }
1629
1663
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1630
1664
  await routeArtifactsOnce();
1631
- const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1632
- responseUserId: ownerId ?? undefined,
1665
+ const responseRouting = buildCodexTurnResponseRouting({
1666
+ requestingUserId: nextTurn.requestingUserId,
1667
+ ownerId,
1668
+ });
1669
+ // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
1670
+ // seeds the server-owned pending node/attention/state and authors the
1671
+ // `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
1672
+ // fresh turn off the server-authored `plan_approval_reply`.
1673
+ await client.createRuntimePlanRequest({
1674
+ conversationId: session.conversationId,
1675
+ planId: session.currentTurnId ?? randomUUID(),
1633
1676
  title: 'Codex Plan',
1634
1677
  body: result.finalMessage,
1635
- });
1636
- await sendMessageWithRetry(client, session.conversationId, planApproval.text, {
1637
- messageId: buildCodexMessageId(session, 'plan'),
1638
- metadata: {
1639
- ...planApproval.metadata,
1640
- turnId: session.currentTurnId,
1641
- turnSemantics: 'control',
1642
- replyBehavior: 'suppress_auto_reply',
1643
- },
1678
+ ...(responseRouting.responseUserId
1679
+ ? { responseUserId: responseRouting.responseUserId }
1680
+ : {}),
1681
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1644
1682
  });
1645
1683
  await handoffFinalMessage(session.conversationId);
1646
1684
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
@@ -1659,6 +1697,7 @@ export async function main() {
1659
1697
  metadata: {
1660
1698
  turnId: session.currentTurnId,
1661
1699
  turnSemantics: 'turn_complete',
1700
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1662
1701
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1663
1702
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1664
1703
  },
@@ -1683,6 +1722,7 @@ export async function main() {
1683
1722
  metadata: {
1684
1723
  turnId: session.currentTurnId,
1685
1724
  turnSemantics: 'turn_complete',
1725
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1686
1726
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1687
1727
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1688
1728
  },
@@ -1719,6 +1759,7 @@ export async function main() {
1719
1759
  metadata: {
1720
1760
  turnId: session.currentTurnId,
1721
1761
  turnSemantics: 'turn_complete',
1762
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
1722
1763
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1723
1764
  },
1724
1765
  }).catch(() => { });
@@ -1729,6 +1770,11 @@ export async function main() {
1729
1770
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1730
1771
  }
1731
1772
  finally {
1773
+ persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
1774
+ if (session.pendingDroppedRecoveryCursor) {
1775
+ persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
1776
+ session.pendingDroppedRecoveryCursor = null;
1777
+ }
1732
1778
  stopVisibleWorkSignal(session);
1733
1779
  session.running = false;
1734
1780
  session.state.state = 'idle';
@@ -1747,6 +1793,30 @@ export async function main() {
1747
1793
  }
1748
1794
  }
1749
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
+ }
1750
1820
  let streamConnected = false;
1751
1821
  const hostAvailableExecutionModes = [
1752
1822
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1866,6 +1936,7 @@ export async function main() {
1866
1936
  if (type === 'stop_and_drop') {
1867
1937
  const droppedPrompts = session.queue.splice(0);
1868
1938
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1939
+ rememberDroppedRecoveryCursor(session, droppedPrompts);
1869
1940
  }
1870
1941
  if (session.running) {
1871
1942
  await session.adapter.interrupt();
@@ -2049,8 +2120,12 @@ export async function main() {
2049
2120
  const message = payload.message;
2050
2121
  if (message.senderId === agentId)
2051
2122
  return;
2123
+ if (!claimInboundMessageId(message.id))
2124
+ return;
2052
2125
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2053
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);
2054
2129
  return;
2055
2130
  }
2056
2131
  void enqueueInboundMessage({
@@ -2063,14 +2138,10 @@ export async function main() {
2063
2138
  selfContexts: payload.selfContexts,
2064
2139
  provenance: payload.provenance,
2065
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);
2066
2144
  });
2067
- if (message.id) {
2068
- saveRuntimeSessionState(runtimeId, {
2069
- conversationId: payload.conversationId,
2070
- baseCwd: workingDir,
2071
- lastInboundMessageId: message.id,
2072
- });
2073
- }
2074
2145
  },
2075
2146
  onMessageDeleted: (payload) => {
2076
2147
  removeQueuedPrompt(payload.conversationId, payload.messageId);
@@ -2106,6 +2177,57 @@ export async function main() {
2106
2177
  catch (error) {
2107
2178
  console.error('[canon-codex] Failed to load startup conversations:', error);
2108
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
+ }
2109
2231
  startCodexStreamInBackground(stream, (error) => {
2110
2232
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2111
2233
  });
@@ -2136,6 +2258,7 @@ export async function main() {
2136
2258
  controlPoller.stop();
2137
2259
  clearInterval(heartbeat);
2138
2260
  clearInterval(idleCheck);
2261
+ runtimeRequests.dispose();
2139
2262
  stream.stop();
2140
2263
  await runtimeState.clearAgentRuntime().catch(() => { });
2141
2264
  for (const session of [...sessions.values()]) {
@@ -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.21.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.0.0",
32
+ "@canonmsg/agent-sdk": "^5.1.1",
33
33
  "@canonmsg/coding-agent-host": "^0.2.2",
34
- "@canonmsg/core": "^4.0.0"
34
+ "@canonmsg/core": "^4.2.2"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"