@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 +12 -0
- package/dist/host.js +248 -36
- package/dist/startup-recovery.d.ts +1 -0
- package/dist/startup-recovery.js +15 -2
- package/package.json +3 -3
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
|
-
|
|
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
|
|
549
|
-
allowSessionRule
|
|
565
|
+
...(responseUserId ? { responseUserId } : {}),
|
|
566
|
+
allowSessionRule,
|
|
550
567
|
expiresAt,
|
|
551
568
|
...(payload.turnId ? { turnId: payload.turnId } : {}),
|
|
552
569
|
});
|
|
553
|
-
return {
|
|
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
|
-
...(
|
|
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;
|
|
1113
|
-
//
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
...(
|
|
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
|
});
|
package/dist/startup-recovery.js
CHANGED
|
@@ -37,8 +37,21 @@ export async function collectMissedInboundMessages(input) {
|
|
|
37
37
|
cursorFound = hasCursor(fresh);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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.
|
|
32
|
+
"@canonmsg/agent-sdk": "^5.1.1",
|
|
33
33
|
"@canonmsg/coding-agent-host": "^0.2.2",
|
|
34
|
-
"@canonmsg/core": "^4.2.
|
|
34
|
+
"@canonmsg/core": "^4.2.3"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=18.0.0"
|