@canonmsg/codex-plugin 0.32.0 → 0.32.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.
@@ -0,0 +1,60 @@
1
+ import type { SendMessageOptions } from '@canonmsg/core';
2
+ export interface CodexCompletedOutput {
3
+ version: 1;
4
+ turnId: string;
5
+ conversationId: string;
6
+ sourceMessageId: string | null;
7
+ nativeThreadId: string | null;
8
+ createdAt: string;
9
+ audience: {
10
+ memberIds: string[];
11
+ membershipRevision?: number;
12
+ } | null;
13
+ output: {
14
+ kind: 'message';
15
+ text: string;
16
+ options: SendMessageOptions & {
17
+ messageId: string;
18
+ };
19
+ } | {
20
+ kind: 'plan';
21
+ body: {
22
+ conversationId: string;
23
+ interactionKind: 'plan';
24
+ planId: string;
25
+ expiresAt: number;
26
+ title: string;
27
+ body: string;
28
+ turnId: string;
29
+ responseUserId?: string;
30
+ };
31
+ } | {
32
+ kind: 'none';
33
+ reason: 'silent' | 'interrupted' | 'empty';
34
+ };
35
+ }
36
+ /** A completed native result is replayable output, never permission to run it again. */
37
+ export declare function createCodexCompletedOutputJournal(directory: string, scope: unknown): {
38
+ save(record: CodexCompletedOutput): void;
39
+ list(): CodexCompletedOutput[];
40
+ remove(turnId: string): void;
41
+ };
42
+ export declare function codexPlanOutputOperationId(record: CodexCompletedOutput): string;
43
+ /** A new delivery after restart must not silently adopt a changed audience. */
44
+ export declare function assertCodexCompletedOutputAudience(record: CodexCompletedOutput, current: {
45
+ memberIds: string[];
46
+ membershipRevision?: number;
47
+ } | null): void;
48
+ /** Deleting nonblank streaming text promotes it to chat history on the server. */
49
+ export declare function clearCodexStreamingWithCompletedSilence(options: {
50
+ records: CodexCompletedOutput[];
51
+ read(): Promise<unknown>;
52
+ blank(turnId: string): Promise<void>;
53
+ clear(): Promise<void>;
54
+ }): Promise<void>;
55
+ /** Delete plaintext only after the exact output is durable and its input is settled. */
56
+ export declare function handoffCodexCompletedOutput(record: CodexCompletedOutput, handlers: {
57
+ publish(record: CodexCompletedOutput): Promise<void>;
58
+ settle(record: CodexCompletedOutput): Promise<void>;
59
+ remove(turnId: string): void;
60
+ }): Promise<void>;
@@ -0,0 +1,149 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /** A completed native result is replayable output, never permission to run it again. */
5
+ export function createCodexCompletedOutputJournal(directory, scope) {
6
+ const folder = join(directory, createHash('sha256').update(JSON.stringify(scope)).digest('hex'));
7
+ const filename = (turnId) => join(folder, `${createHash('sha256').update(turnId).digest('hex')}.json`);
8
+ function syncDirectory() {
9
+ const fd = openSync(folder, 'r');
10
+ try {
11
+ fsyncSync(fd);
12
+ }
13
+ finally {
14
+ closeSync(fd);
15
+ }
16
+ }
17
+ function validate(record) {
18
+ if (record.version !== 1 || typeof record.turnId !== 'string' || !record.turnId
19
+ || typeof record.conversationId !== 'string' || !record.conversationId || typeof record.createdAt !== 'string'
20
+ || !['message', 'plan', 'none'].includes(record.output?.kind)
21
+ || (record.output.kind === 'message' && (typeof record.output.text !== 'string' || typeof record.output.options?.messageId !== 'string'))
22
+ || (record.output.kind === 'plan' && (record.output.body?.planId !== record.turnId || record.output.body?.conversationId !== record.conversationId))
23
+ || (record.output.kind === 'none' && !['silent', 'interrupted', 'empty'].includes(record.output.reason))) {
24
+ throw new Error('Invalid completed Codex output record; operator reconciliation is required');
25
+ }
26
+ }
27
+ function recoverTemporaryFiles() {
28
+ for (const name of readdirSync(folder)) {
29
+ const match = /^([a-f0-9]{64})\.(\d+)\.[a-f0-9-]+\.tmp$/.exec(name);
30
+ if (!match)
31
+ continue;
32
+ try {
33
+ process.kill(Number(match[2]), 0);
34
+ continue;
35
+ }
36
+ catch (error) {
37
+ if (error.code !== 'ESRCH')
38
+ continue;
39
+ }
40
+ const path = join(folder, name);
41
+ let record;
42
+ try {
43
+ record = JSON.parse(readFileSync(path, 'utf8'));
44
+ validate(record);
45
+ }
46
+ catch {
47
+ // A torn write never established a completed result. Its input stays
48
+ // uncertain; the native transcript is the remaining recovery source.
49
+ unlinkSync(path);
50
+ throw new Error('Incomplete Codex output write removed; reconcile its native transcript');
51
+ }
52
+ const destination = filename(record.turnId);
53
+ if (destination !== join(folder, `${match[1]}.json`))
54
+ throw new Error('Completed Codex output temporary file identity mismatch');
55
+ if (existsSync(destination)) {
56
+ if (readFileSync(destination, 'utf8') !== JSON.stringify(record))
57
+ throw new Error('Conflicting completed Codex outputs require reconciliation');
58
+ unlinkSync(path);
59
+ }
60
+ else
61
+ renameSync(path, destination);
62
+ syncDirectory();
63
+ }
64
+ }
65
+ return {
66
+ save(record) {
67
+ validate(record);
68
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
69
+ const path = filename(record.turnId);
70
+ const serialized = JSON.stringify(record);
71
+ if (existsSync(path)) {
72
+ if (readFileSync(path, 'utf8') !== serialized)
73
+ throw new Error('Completed Codex output cannot be replaced');
74
+ return;
75
+ }
76
+ const temporary = `${path.slice(0, -5)}.${process.pid}.${randomUUID()}.tmp`;
77
+ try {
78
+ const fd = openSync(temporary, 'wx', 0o600);
79
+ try {
80
+ writeFileSync(fd, serialized);
81
+ fsyncSync(fd);
82
+ }
83
+ finally {
84
+ closeSync(fd);
85
+ }
86
+ renameSync(temporary, path);
87
+ syncDirectory();
88
+ }
89
+ catch (error) {
90
+ try {
91
+ unlinkSync(temporary);
92
+ }
93
+ catch { /* Renamed records remain recoverable. */ }
94
+ throw error;
95
+ }
96
+ },
97
+ list() {
98
+ if (!existsSync(folder))
99
+ return [];
100
+ recoverTemporaryFiles();
101
+ return readdirSync(folder).filter((name) => name.endsWith('.json')).map((name) => {
102
+ const record = JSON.parse(readFileSync(join(folder, name), 'utf8'));
103
+ validate(record);
104
+ if (filename(record.turnId) !== join(folder, name))
105
+ throw new Error('Completed Codex output identity mismatch');
106
+ return record;
107
+ }).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
108
+ },
109
+ remove(turnId) {
110
+ try {
111
+ unlinkSync(filename(turnId));
112
+ syncDirectory();
113
+ }
114
+ catch (error) {
115
+ if (error.code !== 'ENOENT')
116
+ throw error;
117
+ }
118
+ },
119
+ };
120
+ }
121
+ export function codexPlanOutputOperationId(record) {
122
+ return createHash('sha256').update(JSON.stringify(['codex-plan-output', record.conversationId, record.turnId])).digest('hex');
123
+ }
124
+ /** A new delivery after restart must not silently adopt a changed audience. */
125
+ export function assertCodexCompletedOutputAudience(record, current) {
126
+ if (!record.audience || !current
127
+ || (record.audience.membershipRevision !== undefined && record.audience.membershipRevision !== current.membershipRevision)
128
+ || JSON.stringify([...record.audience.memberIds].sort()) !== JSON.stringify([...current.memberIds].sort())) {
129
+ throw new Error('Completed Codex output audience changed; operator reconciliation is required');
130
+ }
131
+ }
132
+ /** Deleting nonblank streaming text promotes it to chat history on the server. */
133
+ export async function clearCodexStreamingWithCompletedSilence(options) {
134
+ const silentTurns = new Set(options.records.filter((record) => record.output.kind === 'none' && record.output.reason === 'silent').map((record) => record.turnId));
135
+ if (!silentTurns.size)
136
+ return;
137
+ const value = await options.read();
138
+ const turnId = value?.turnId ?? value?.messageId;
139
+ if (!turnId || !silentTurns.has(turnId))
140
+ return;
141
+ await options.blank(turnId);
142
+ await options.clear();
143
+ }
144
+ /** Delete plaintext only after the exact output is durable and its input is settled. */
145
+ export async function handoffCodexCompletedOutput(record, handlers) {
146
+ await handlers.publish(record);
147
+ await handlers.settle(record);
148
+ handlers.remove(record.turnId);
149
+ }
@@ -2,3 +2,12 @@ export interface LongLivedStream {
2
2
  start(): Promise<void>;
3
3
  }
4
4
  export declare function startCodexStreamInBackground(stream: LongLivedStream, onError: (error: unknown) => void): void;
5
+ /** Only fully initialized sessions may be reused by another inbound offer. */
6
+ export declare function initializeCodexSession<T>(options: {
7
+ key: string;
8
+ sessions: Map<string, T>;
9
+ create(): Promise<T>;
10
+ cleanup(): void;
11
+ }): Promise<T>;
12
+ /** Only an explicit local policy/configuration error may settle an unstarted input. */
13
+ export declare function classifyCodexSessionStart(error: unknown): 'configuration_required' | 'deferred';
@@ -1,3 +1,20 @@
1
+ import { ExecutionEnvironmentError } from '@canonmsg/core';
1
2
  export function startCodexStreamInBackground(stream, onError) {
2
3
  stream.start().catch(onError);
3
4
  }
5
+ /** Only fully initialized sessions may be reused by another inbound offer. */
6
+ export async function initializeCodexSession(options) {
7
+ try {
8
+ const session = await options.create();
9
+ options.sessions.set(options.key, session);
10
+ return session;
11
+ }
12
+ catch (error) {
13
+ options.cleanup();
14
+ throw error;
15
+ }
16
+ }
17
+ /** Only an explicit local policy/configuration error may settle an unstarted input. */
18
+ export function classifyCodexSessionStart(error) {
19
+ return error instanceof ExecutionEnvironmentError ? 'configuration_required' : 'deferred';
20
+ }
package/dist/host.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { dirname } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
8
  import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
@@ -16,7 +16,8 @@ import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThrea
16
16
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
17
17
  import { detectCodexCliVersion } from './codex-cli-version.js';
18
18
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
19
- import { startCodexStreamInBackground } from './host-lifecycle.js';
19
+ import { classifyCodexSessionStart, initializeCodexSession, startCodexStreamInBackground } from './host-lifecycle.js';
20
+ import { clearCodexStreamingWithCompletedSilence, assertCodexCompletedOutputAudience, createCodexCompletedOutputJournal, codexPlanOutputOperationId, handoffCodexCompletedOutput } from './completed-output.js';
20
21
  import { createCodexControlPoller } from './control-channel.js';
21
22
  import { runCli, acquireLock } from '@canonmsg/core';
22
23
  import { runCodexWorkSessionHost } from './work-session-cli.js';
@@ -816,6 +817,94 @@ export async function main() {
816
817
  hostMode: true,
817
818
  rtdb,
818
819
  });
820
+ const endpointIdentity = await endpoint.ready();
821
+ const completedOutputs = createCodexCompletedOutputJournal(join(CANON_DIR, 'codex-completed-output'), {
822
+ runtimeId, environmentId: endpointIdentity.environmentId, principalId: endpointIdentity.principalId,
823
+ ownershipVersion: endpointIdentity.ownershipVersion ?? null, installationId: endpointIdentity.installationId,
824
+ });
825
+ const activeCompletedOutputs = new Set();
826
+ const recoveringOutputRooms = new Map();
827
+ let recoveringCompletedOutputs;
828
+ async function publishCompletedPlan(record) {
829
+ if (record.output.kind !== 'plan')
830
+ throw new Error('Expected completed Codex plan');
831
+ const operationId = codexPlanOutputOperationId(record);
832
+ if (await endpoint.getOperation(operationId)) {
833
+ return client.waitForOperation(operationId, { timeoutMs: 5_000 });
834
+ }
835
+ return endpoint.executeResult({ action: 'create_interaction', body: record.output.body }, { operationId });
836
+ }
837
+ runtimeRequests.register('plan', {
838
+ ...runtimePlanDescriptor,
839
+ async create(input) {
840
+ const record = completedOutputs.list().find((item) => item.turnId === input.requestId && item.conversationId === input.conversationId);
841
+ if (!record || record.output.kind !== 'plan')
842
+ return runtimePlanDescriptor.create(input);
843
+ const created = await publishCompletedPlan(record);
844
+ return { requestId: created.planId, expiresAt: created.expiresAt, messageId: created.messageId };
845
+ },
846
+ });
847
+ async function deliverCompletedOutput(record, alreadyPublished = false) {
848
+ await handoffCodexCompletedOutput(record, {
849
+ publish: async () => {
850
+ if (alreadyPublished)
851
+ return;
852
+ if (record.output.kind === 'none') {
853
+ if (record.output.reason === 'silent')
854
+ await clearCompletedSilentStreaming(record.conversationId);
855
+ return;
856
+ }
857
+ if (record.output.kind === 'plan')
858
+ await publishCompletedPlan(record);
859
+ else {
860
+ const existing = await client.getMessageOperation(record.conversationId, record.output.options.messageId);
861
+ if (existing)
862
+ await client.waitForOperation(existing.operationId, { timeoutMs: 5_000 });
863
+ else
864
+ await sendMessageWithRetryChunked(client, record.conversationId, record.output.text, record.output.options, { timeoutMs: 5_000 });
865
+ }
866
+ },
867
+ settle: async () => {
868
+ if (record.sourceMessageId)
869
+ await endpoint.setInboundState(`message:${record.conversationId}:${record.sourceMessageId}`, 'settled', 'native-output-handed-off');
870
+ },
871
+ remove: completedOutputs.remove,
872
+ });
873
+ }
874
+ function recoverCompletedOutputs() {
875
+ if (recoveringCompletedOutputs)
876
+ return recoveringCompletedOutputs;
877
+ recoveringCompletedOutputs = (async () => {
878
+ for (const record of completedOutputs.list()) {
879
+ if (activeCompletedOutputs.has(record.turnId) || sessions.get(record.conversationId)?.running)
880
+ continue;
881
+ const recovery = (async () => {
882
+ const existing = record.output.kind === 'plan'
883
+ ? await endpoint.getOperation(codexPlanOutputOperationId(record))
884
+ : record.output.kind === 'message'
885
+ ? await client.getMessageOperation(record.conversationId, record.output.options.messageId)
886
+ : undefined;
887
+ if (record.output.kind !== 'none' && !existing) {
888
+ assertCodexCompletedOutputAudience(record, await client.getConversation(record.conversationId));
889
+ }
890
+ await deliverCompletedOutput(record);
891
+ })();
892
+ recoveringOutputRooms.set(record.conversationId, recovery);
893
+ try {
894
+ await recovery;
895
+ }
896
+ catch (error) {
897
+ // Retain unresolved output, including rejected/uncertain delivery.
898
+ // Never rescan workspace files or submit another native turn here.
899
+ console.error(`[canon-codex] Completed output ${record.turnId} awaits delivery reconciliation:`, error);
900
+ }
901
+ finally {
902
+ recoveringOutputRooms.delete(record.conversationId);
903
+ }
904
+ }
905
+ })().finally(() => { recoveringCompletedOutputs = undefined; });
906
+ return recoveringCompletedOutputs;
907
+ }
819
908
  const sessions = new Map();
820
909
  const pendingSessionCreations = new Map();
821
910
  const recoveryCheckpointTrackers = new Map();
@@ -991,6 +1080,14 @@ export async function main() {
991
1080
  settleRejectedPromptCheckpoints(conversationId, removed);
992
1081
  writeTurn(session);
993
1082
  }
1083
+ async function clearCompletedSilentStreaming(conversationId) {
1084
+ await clearCodexStreamingWithCompletedSilence({
1085
+ records: completedOutputs.list().filter((record) => record.conversationId === conversationId),
1086
+ read: () => rtdb.read(`/streaming/${conversationId}/${agentId}`),
1087
+ blank: (turnId) => runtimeState.writeStreaming(conversationId, { text: '', status: 'thinking', messageId: turnId, turnId, blocks: [] }),
1088
+ clear: () => runtimeState.clearStreaming(conversationId),
1089
+ });
1090
+ }
994
1091
  function clearStreaming(conversationId) {
995
1092
  runtimeState.clearStreaming(conversationId).catch(() => { });
996
1093
  }
@@ -1028,7 +1125,7 @@ export async function main() {
1028
1125
  messageId: session.currentTurnId ?? undefined,
1029
1126
  turnId: session.currentTurnId,
1030
1127
  blocks: [],
1031
- }).catch(() => { });
1128
+ });
1032
1129
  }
1033
1130
  function upsertCodexTextSegment(session, event) {
1034
1131
  const next = applyTextSegmentBlock({
@@ -1091,7 +1188,7 @@ export async function main() {
1091
1188
  }
1092
1189
  async function handoffFinalMessage(conversationId) {
1093
1190
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
1094
- clearStreaming(conversationId);
1191
+ await runtimeState.clearStreaming(conversationId);
1095
1192
  typingSignals.clear(conversationId).catch(() => { });
1096
1193
  }
1097
1194
  function refreshVisibleWorkSignal(session) {
@@ -1239,136 +1336,146 @@ export async function main() {
1239
1336
  workspaceCwd,
1240
1337
  allowWorktrees: sessionExecutionMode === 'worktree',
1241
1338
  });
1242
- try {
1243
- const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
1244
- conversationId,
1245
- baseCwd: environment.baseCwd,
1246
- executionMode: environment.mode,
1247
- resumeField: 'threadId',
1248
- configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
1249
- availableExecutionModes: hostAvailableExecutionModes,
1250
- });
1251
- if (persistedMapping.status === 'configuration_required') {
1252
- throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1253
- }
1254
- if (persistedMapping.status === 'adopted'
1255
- && (persistedMapping.state.baseCwd !== environment.baseCwd
1256
- || persistedMapping.state.executionMode !== environment.mode)) {
1257
- const restoredEnvironment = prepareConversationEnvironment({
1258
- agentId,
1339
+ let createdAdapter;
1340
+ return initializeCodexSession({
1341
+ key: conversationId,
1342
+ sessions,
1343
+ create: async () => {
1344
+ const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
1259
1345
  conversationId,
1260
- workspaceCwd: persistedMapping.state.baseCwd,
1261
- allowWorktrees: persistedMapping.state.executionMode === 'worktree',
1346
+ baseCwd: environment.baseCwd,
1347
+ executionMode: environment.mode,
1348
+ resumeField: 'threadId',
1349
+ configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
1350
+ availableExecutionModes: hostAvailableExecutionModes,
1262
1351
  });
1263
- if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
1264
- releaseConversationEnvironment(restoredEnvironment);
1265
- throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1352
+ if (persistedMapping.status === 'configuration_required') {
1353
+ throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1266
1354
  }
1267
- releaseConversationEnvironment(environment);
1268
- environment = restoredEnvironment;
1269
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
1270
- }
1271
- const sessionCwd = environment.cwd;
1272
- const policy = resolveCodexEffectiveRuntimePolicy({
1273
- args,
1274
- config: null,
1275
- permissionEnvelope: codexPermissionEnvelope,
1276
- environment,
1277
- serviceAgentMode,
1278
- });
1279
- const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
1280
- if (modelGuard) {
1281
- throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1282
- }
1283
- const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
1284
- ...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
1285
- ? {}
1286
- : { workspaceId: persistedMapping.state.workspaceId }),
1287
- allowLegacyPolicyMigration: persistedMapping.status !== 'new',
1288
- });
1289
- const effectiveModel = policy.model ?? codexDefaultModel;
1290
- const initialEffortResolution = resolveCodexEffortForModel({
1291
- models: codexModels,
1292
- model: effectiveModel,
1293
- requestedEffort: configuredCodexEffort,
1294
- });
1295
- const initialEffort = initialEffortResolution.value;
1296
- const adapter = useAppServer
1297
- ? new CodexAppServerAdapter({
1298
- cwd: sessionCwd,
1299
- threadId: storedThreadId,
1300
- codexBin,
1301
- model: policy.model ?? null,
1302
- reasoningEffort: initialEffort,
1303
- sandbox: policy.sandbox,
1304
- addDirs: args['add-dir'] ?? [],
1305
- configOverrides: args.config ?? [],
1306
- fullAuto: policy.fullAuto,
1307
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1308
- dynamicTools: codexDynamicTools,
1309
- })
1310
- : new CodexConversationAdapter({
1355
+ if (persistedMapping.status === 'adopted'
1356
+ && (persistedMapping.state.baseCwd !== environment.baseCwd
1357
+ || persistedMapping.state.executionMode !== environment.mode)) {
1358
+ const restoredEnvironment = prepareConversationEnvironment({
1359
+ agentId,
1360
+ conversationId,
1361
+ workspaceCwd: persistedMapping.state.baseCwd,
1362
+ allowWorktrees: persistedMapping.state.executionMode === 'worktree',
1363
+ });
1364
+ if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
1365
+ releaseConversationEnvironment(restoredEnvironment);
1366
+ throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1367
+ }
1368
+ releaseConversationEnvironment(environment);
1369
+ environment = restoredEnvironment;
1370
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
1371
+ }
1372
+ const sessionCwd = environment.cwd;
1373
+ const policy = resolveCodexEffectiveRuntimePolicy({
1374
+ args,
1375
+ config: null,
1376
+ permissionEnvelope: codexPermissionEnvelope,
1377
+ environment,
1378
+ serviceAgentMode,
1379
+ });
1380
+ const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
1381
+ if (modelGuard) {
1382
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1383
+ }
1384
+ const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
1385
+ ...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
1386
+ ? {}
1387
+ : { workspaceId: persistedMapping.state.workspaceId }),
1388
+ allowLegacyPolicyMigration: persistedMapping.status !== 'new',
1389
+ });
1390
+ const effectiveModel = policy.model ?? codexDefaultModel;
1391
+ const initialEffortResolution = resolveCodexEffortForModel({
1392
+ models: codexModels,
1393
+ model: effectiveModel,
1394
+ requestedEffort: configuredCodexEffort,
1395
+ });
1396
+ const initialEffort = initialEffortResolution.value;
1397
+ const adapter = useAppServer
1398
+ ? new CodexAppServerAdapter({
1399
+ cwd: sessionCwd,
1400
+ threadId: storedThreadId,
1401
+ codexBin,
1402
+ model: policy.model ?? null,
1403
+ reasoningEffort: initialEffort,
1404
+ sandbox: policy.sandbox,
1405
+ addDirs: args['add-dir'] ?? [],
1406
+ configOverrides: args.config ?? [],
1407
+ fullAuto: policy.fullAuto,
1408
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1409
+ dynamicTools: codexDynamicTools,
1410
+ })
1411
+ : new CodexConversationAdapter({
1412
+ cwd: sessionCwd,
1413
+ threadId: storedThreadId,
1414
+ codexBin,
1415
+ model: policy.model ?? null,
1416
+ reasoningEffort: initialEffort,
1417
+ sandbox: policy.sandbox,
1418
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
1419
+ addDirs: args['add-dir'] ?? [],
1420
+ configOverrides: args.config ?? [],
1421
+ fullAuto: policy.fullAuto,
1422
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1423
+ });
1424
+ createdAdapter = adapter;
1425
+ const session = {
1426
+ conversationId,
1311
1427
  cwd: sessionCwd,
1312
- threadId: storedThreadId,
1313
- codexBin,
1314
- model: policy.model ?? null,
1315
- reasoningEffort: initialEffort,
1316
- sandbox: policy.sandbox,
1317
- codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
1318
- addDirs: args['add-dir'] ?? [],
1319
- configOverrides: args.config ?? [],
1320
- fullAuto: policy.fullAuto,
1321
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1428
+ environment,
1429
+ adapter,
1430
+ queue: [],
1431
+ running: false,
1432
+ state: buildCodexInitialSessionState({
1433
+ model: effectiveModel ?? undefined,
1434
+ permissionMode: policy.permissionMode,
1435
+ effort: initialEffort,
1436
+ }),
1437
+ policyFingerprint: policy.fingerprint,
1438
+ turnState: 'idle',
1439
+ currentTurnId: null,
1440
+ currentTurnOpenedAt: null,
1441
+ currentTurnUpdatedAt: null,
1442
+ currentTurnCanUseCodexAppTools: false,
1443
+ currentReplyAuthority: null,
1444
+ currentTurnAbortController: null,
1445
+ // Corrected by the first turn that runs; a session with no turn
1446
+ // publishes nothing anyway, and quiet is never an accident.
1447
+ turnVerbosity: 'verbose',
1448
+ currentTurnSilenced: false,
1449
+ activeSelfContextId: null,
1450
+ lastAcceptedIntent: null,
1451
+ resetRequested: false,
1452
+ lastActivity: Date.now(),
1453
+ typingKeepaliveTimer: null,
1454
+ closed: false,
1455
+ turnLiveText: '',
1456
+ turnBlocks: [],
1457
+ turnCommandBlocks: createCommandBlockTracker(),
1458
+ };
1459
+ await controlPoller.baseline([conversationId]);
1460
+ await runtimeState.patchAgentSessionSnapshot(conversationId, {
1461
+ configurationStatus: 'ready',
1462
+ lastError: null,
1322
1463
  });
1323
- const session = {
1324
- conversationId,
1325
- cwd: sessionCwd,
1326
- environment,
1327
- adapter,
1328
- queue: [],
1329
- running: false,
1330
- state: buildCodexInitialSessionState({
1331
- model: effectiveModel ?? undefined,
1332
- permissionMode: policy.permissionMode,
1333
- effort: initialEffort,
1334
- }),
1335
- policyFingerprint: policy.fingerprint,
1336
- turnState: 'idle',
1337
- currentTurnId: null,
1338
- currentTurnOpenedAt: null,
1339
- currentTurnUpdatedAt: null,
1340
- currentTurnCanUseCodexAppTools: false,
1341
- currentReplyAuthority: null,
1342
- currentTurnAbortController: null,
1343
- // Corrected by the first turn that runs; a session with no turn
1344
- // publishes nothing anyway, and quiet is never an accident.
1345
- turnVerbosity: 'verbose',
1346
- currentTurnSilenced: false,
1347
- activeSelfContextId: null,
1348
- lastAcceptedIntent: null,
1349
- resetRequested: false,
1350
- lastActivity: Date.now(),
1351
- typingKeepaliveTimer: null,
1352
- closed: false,
1353
- turnLiveText: '',
1354
- turnBlocks: [],
1355
- turnCommandBlocks: createCommandBlockTracker(),
1356
- };
1357
- sessions.set(conversationId, session);
1358
- await controlPoller.baseline([conversationId]);
1359
- await runtimeState.patchAgentSessionSnapshot(conversationId, {
1360
- configurationStatus: 'ready',
1361
- lastError: null,
1362
- });
1363
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1364
- writeState(session);
1365
- writeTurn(session);
1366
- return session;
1367
- }
1368
- catch (error) {
1369
- releaseConversationEnvironment(environment);
1370
- throw error;
1371
- }
1464
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1465
+ writeState(session);
1466
+ writeTurn(session);
1467
+ return session;
1468
+ },
1469
+ cleanup: () => {
1470
+ try {
1471
+ if (createdAdapter instanceof CodexAppServerAdapter)
1472
+ createdAdapter.close();
1473
+ }
1474
+ finally {
1475
+ releaseConversationEnvironment(environment);
1476
+ }
1477
+ },
1478
+ });
1372
1479
  })();
1373
1480
  pendingSessionCreations.set(conversationId, creation);
1374
1481
  try {
@@ -1897,6 +2004,11 @@ export async function main() {
1897
2004
  catch (error) {
1898
2005
  const message = error instanceof Error ? error.message : String(error);
1899
2006
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
2007
+ // No native input has been claimed or submitted. The endpoint can offer
2008
+ // this input again after a service/storage failure; do not settle it or
2009
+ // misreport a temporary API failure as missing local configuration.
2010
+ if (classifyCodexSessionStart(error) === 'deferred')
2011
+ throw error;
1900
2012
  await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
1901
2013
  configurationStatus: 'configuration_required',
1902
2014
  lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
@@ -1967,12 +2079,36 @@ export async function main() {
1967
2079
  async function runNextTurn(session) {
1968
2080
  if (session.running || session.closed)
1969
2081
  return;
2082
+ // Reserve the room before awaiting recovery so a new turn cannot race an
2083
+ // older completion's streaming teardown. The queue remains unclaimed on
2084
+ // failure and the ordinary host heartbeat retries it.
2085
+ session.running = true;
2086
+ try {
2087
+ await recoveringOutputRooms.get(session.conversationId);
2088
+ for (const record of completedOutputs.list()) {
2089
+ if (record.conversationId === session.conversationId && record.output.kind === 'none' && record.output.reason === 'silent') {
2090
+ await deliverCompletedOutput(record);
2091
+ }
2092
+ }
2093
+ }
2094
+ catch (error) {
2095
+ session.running = false;
2096
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Prior completion still needs cleanup:`, error);
2097
+ return;
2098
+ }
2099
+ if (session.closed || sessions.get(session.conversationId) !== session) {
2100
+ session.running = false;
2101
+ return;
2102
+ }
1970
2103
  const nextTurn = session.queue.shift();
1971
- if (!nextTurn)
2104
+ if (!nextTurn) {
2105
+ session.running = false;
1972
2106
  return;
1973
- session.running = true;
2107
+ }
1974
2108
  const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
1975
2109
  let journaledInput = false;
2110
+ let completedOutput;
2111
+ let nativeCompleted = false;
1976
2112
  session.state.lastError = undefined;
1977
2113
  session.state.state = 'running';
1978
2114
  session.currentTurnId = randomUUID();
@@ -2211,8 +2347,49 @@ export async function main() {
2211
2347
  session.currentTurnSilenced = false;
2212
2348
  result = await runTurnOnce();
2213
2349
  }
2214
- if (journaledInput)
2215
- await endpoint.setInboundState(inboundId, 'settled', 'native-completed');
2350
+ nativeCompleted = true;
2351
+ const turnTrail = buildFinalTurnTrail(session);
2352
+ const deliveryMetadata = {
2353
+ turnId: session.currentTurnId,
2354
+ turnSemantics: 'turn_complete',
2355
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
2356
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
2357
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
2358
+ };
2359
+ const canDeliver = !result.interrupted && !!result.finalMessage && resolveSilentTurnDelivery({
2360
+ silenced: session.currentTurnSilenced, finalText: result.finalMessage,
2361
+ }) === 'deliver';
2362
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId: nextTurn.requestingUserId, ownerId });
2363
+ const output = canDeliver && nextTurn.planMode
2364
+ ? { kind: 'plan', body: {
2365
+ conversationId: session.conversationId, interactionKind: 'plan', planId: session.currentTurnId,
2366
+ expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS, title: 'Codex Plan', body: result.finalMessage, turnId: session.currentTurnId,
2367
+ ...(responseRouting.responseUserId ? { responseUserId: responseRouting.responseUserId } : {}),
2368
+ } }
2369
+ : canDeliver || (!result.interrupted && !!result.exitCode)
2370
+ ? { kind: 'message', text: canDeliver ? result.finalMessage : formatCodexTurnFailure(result.errorText), options: {
2371
+ messageId: buildCodexMessageId(session, canDeliver ? 'final' : 'error'),
2372
+ ...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
2373
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
2374
+ metadata: deliveryMetadata,
2375
+ } }
2376
+ : { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
2377
+ const record = {
2378
+ version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
2379
+ sourceMessageId: journaledInput ? nextTurn.sourceMessageId : null,
2380
+ nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
2381
+ audience: conversationCache.has(session.conversationId) ? {
2382
+ memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
2383
+ ...(conversationCache.get(session.conversationId).membershipRevision !== undefined
2384
+ ? { membershipRevision: conversationCache.get(session.conversationId).membershipRevision } : {}),
2385
+ } : null,
2386
+ };
2387
+ // This is the first local evidence that native work completed. A crash
2388
+ // before this fsynced record remains uncertain and needs its transcript;
2389
+ // after it, recovery only republishes the exact saved Canon output.
2390
+ completedOutputs.save(record);
2391
+ completedOutput = record;
2392
+ activeCompletedOutputs.add(record.turnId);
2216
2393
  // Both the artifact gate and the final delivery weigh silence against
2217
2394
  // this text, and they must weigh the same one — set it before any
2218
2395
  // completion branch runs, including the ones that route first.
@@ -2231,23 +2408,10 @@ export async function main() {
2231
2408
  if (result.threadId && !session.resetRequested) {
2232
2409
  saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
2233
2410
  }
2234
- if (!result.interrupted
2235
- && result.finalMessage
2236
- && nextTurn.planMode
2237
- // A plan card carries the model's final text into the conversation as a
2238
- // visible, actionable artifact — it IS posting. Silence is strict here
2239
- // too: a silenced plan turn falls through to the silent teardown below
2240
- // and raises no card.
2241
- && resolveSilentTurnDelivery({
2242
- silenced: session.currentTurnSilenced,
2243
- finalText: result.finalMessage,
2244
- }) === 'deliver') {
2411
+ if (completedOutput.output.kind === 'plan') {
2245
2412
  await routeArtifactsOnce();
2246
- const responseRouting = buildCodexTurnResponseRouting({
2247
- requestingUserId: nextTurn.requestingUserId,
2248
- ownerId,
2249
- });
2250
- const planId = session.currentTurnId ?? randomUUID();
2413
+ const plan = completedOutput.output.body;
2414
+ const planId = plan.planId;
2251
2415
  let planCreated = false;
2252
2416
  let resolvePlanCreated;
2253
2417
  let rejectPlanCreated;
@@ -2256,17 +2420,14 @@ export async function main() {
2256
2420
  rejectPlanCreated = reject;
2257
2421
  });
2258
2422
  const planReview = runtimeRequests.request('plan', session.conversationId, {
2259
- title: 'Codex Plan',
2260
- body: result.finalMessage,
2261
- ...(responseRouting.responseUserId
2262
- ? { responseUserId: responseRouting.responseUserId }
2263
- : {}),
2264
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
2423
+ title: plan.title, body: plan.body, turnId: plan.turnId,
2424
+ ...(plan.responseUserId ? { responseUserId: plan.responseUserId } : {}),
2265
2425
  }, {
2266
2426
  requestId: planId,
2267
- expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS,
2427
+ expiresAt: plan.expiresAt,
2268
2428
  responderPolicy: 'infer',
2269
- onCreated: () => {
2429
+ onCreated: async () => {
2430
+ await deliverCompletedOutput(completedOutput, true);
2270
2431
  planCreated = true;
2271
2432
  session.turnState = 'waiting_input';
2272
2433
  writeTurn(session);
@@ -2275,7 +2436,7 @@ export async function main() {
2275
2436
  });
2276
2437
  void planReview.then((planResult) => {
2277
2438
  resolvePlanCreated();
2278
- enqueueCodexPlanReviewResult(session, planResult, responseRouting.responseUserId ?? null);
2439
+ enqueueCodexPlanReviewResult(session, planResult, plan.responseUserId ?? null);
2279
2440
  }).catch((error) => {
2280
2441
  if (!planCreated) {
2281
2442
  rejectPlanCreated(error);
@@ -2287,40 +2448,16 @@ export async function main() {
2287
2448
  await handoffFinalMessage(session.conversationId);
2288
2449
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
2289
2450
  }
2290
- else if (!result.interrupted
2291
- && result.finalMessage
2292
- // A turn that called `no_reply` posts nothing. The failure branches
2293
- // below are deliberately outside this gate: silence suppresses the
2294
- // MODEL's reply, never Canon's own "this turn broke" diagnostic.
2295
- && resolveSilentTurnDelivery({
2296
- silenced: session.currentTurnSilenced,
2297
- finalText: result.finalMessage,
2298
- }) === 'deliver') {
2451
+ else if (completedOutput.output.kind === 'message' && canDeliver) {
2299
2452
  if (isRecoverableCodexThreadError(result.errorText)) {
2300
2453
  clearStoredThread();
2301
2454
  }
2302
2455
  await routeArtifactsOnce();
2303
- const turnTrail = buildFinalTurnTrail(session);
2304
- await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
2305
- messageId: buildCodexMessageId(session, 'final'),
2306
- ...(session.currentReplyAuthority
2307
- ? { replyAuthority: session.currentReplyAuthority }
2308
- : {}),
2309
- ...(session.activeSelfContextId
2310
- ? { selfContextId: session.activeSelfContextId }
2311
- : {}),
2312
- metadata: {
2313
- turnId: session.currentTurnId,
2314
- turnSemantics: 'turn_complete',
2315
- ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
2316
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
2317
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
2318
- },
2319
- });
2456
+ await deliverCompletedOutput(completedOutput);
2320
2457
  await handoffFinalMessage(session.conversationId);
2321
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
2458
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${completedOutput.output.text.length} chars)`);
2322
2459
  }
2323
- else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
2460
+ else if (completedOutput.output.kind === 'message') {
2324
2461
  await routeArtifactsOnce();
2325
2462
  const userVisibleError = formatCodexTurnFailure(result.errorText);
2326
2463
  session.state.lastError = userVisibleError;
@@ -2328,23 +2465,7 @@ export async function main() {
2328
2465
  if (result.errorText) {
2329
2466
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
2330
2467
  }
2331
- const turnTrail = buildFinalTurnTrail(session);
2332
- await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
2333
- messageId: buildCodexMessageId(session, 'error'),
2334
- ...(session.currentReplyAuthority
2335
- ? { replyAuthority: session.currentReplyAuthority }
2336
- : {}),
2337
- ...(session.activeSelfContextId
2338
- ? { selfContextId: session.activeSelfContextId }
2339
- : {}),
2340
- metadata: {
2341
- turnId: session.currentTurnId,
2342
- turnSemantics: 'turn_complete',
2343
- ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
2344
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
2345
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
2346
- },
2347
- });
2468
+ await deliverCompletedOutput(completedOutput);
2348
2469
  await handoffFinalMessage(session.conversationId);
2349
2470
  }
2350
2471
  else if (!result.interrupted) {
@@ -2362,7 +2483,7 @@ export async function main() {
2362
2483
  // typing dots go together — leaving dots behind the removed row for
2363
2484
  // the handoff window reads as "started to answer, then gave up".
2364
2485
  await blankCodexStreaming(session);
2365
- clearStreaming(session.conversationId);
2486
+ await runtimeState.clearStreaming(session.conversationId);
2366
2487
  stopVisibleWorkSignal(session);
2367
2488
  }
2368
2489
  await handoffFinalMessage(session.conversationId);
@@ -2375,8 +2496,18 @@ export async function main() {
2375
2496
  typingSignals.clear(session.conversationId).catch(() => { });
2376
2497
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
2377
2498
  }
2499
+ if (completedOutput.output.kind === 'none')
2500
+ await deliverCompletedOutput(completedOutput);
2378
2501
  }
2379
2502
  catch (error) {
2503
+ if (nativeCompleted) {
2504
+ session.state.lastError = completedOutput
2505
+ ? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
2506
+ : 'Native work completed, but its output could not be journaled. Operator reconciliation is required.';
2507
+ writeState(session);
2508
+ console.error(`[canon-codex] Native completion ${session.currentTurnId} needs reconciliation:`, error);
2509
+ return;
2510
+ }
2380
2511
  if (isPendingCanonOperation(error)) {
2381
2512
  session.state.lastError = 'Canon delivery remains pending reconciliation.';
2382
2513
  writeState(session);
@@ -2415,6 +2546,8 @@ export async function main() {
2415
2546
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
2416
2547
  }
2417
2548
  finally {
2549
+ if (completedOutput)
2550
+ activeCompletedOutputs.delete(completedOutput.turnId);
2418
2551
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2419
2552
  session.currentTurnAbortController = null;
2420
2553
  recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
@@ -2725,7 +2858,6 @@ export async function main() {
2725
2858
  });
2726
2859
  },
2727
2860
  onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
2728
- inbound.start();
2729
2861
  const stream = new CanonStream({
2730
2862
  endpoint,
2731
2863
  agentId,
@@ -2768,7 +2900,8 @@ export async function main() {
2768
2900
  for (const conversation of conversations) {
2769
2901
  knownConversationIds.add(conversation.id);
2770
2902
  conversationCache.set(conversation.id, conversation);
2771
- clearStreaming(conversation.id);
2903
+ await clearCompletedSilentStreaming(conversation.id);
2904
+ await runtimeState.clearStreaming(conversation.id);
2772
2905
  runtimeState.clearSessionState(conversation.id).catch(() => { });
2773
2906
  runtimeState.clearTurnState(conversation.id).catch(() => { });
2774
2907
  }
@@ -2779,15 +2912,20 @@ export async function main() {
2779
2912
  await reconnectRecovery.recoverNow().catch((error) => {
2780
2913
  console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2781
2914
  });
2915
+ void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
2916
+ inbound.start();
2782
2917
  startCodexStreamInBackground(stream, (error) => {
2783
2918
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2784
2919
  });
2785
2920
  controlPoller.start();
2786
2921
  const heartbeat = setInterval(() => {
2922
+ void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
2787
2923
  for (const session of sessions.values()) {
2788
2924
  writeState(session);
2789
2925
  if (!session.running) {
2790
2926
  writeTurn(session);
2927
+ if (session.queue.length)
2928
+ void runNextTurn(session);
2791
2929
  }
2792
2930
  }
2793
2931
  publishLocalHeartbeat();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "@canonmsg/agent-sdk": "^11.0.0",
35
35
  "@canonmsg/agent-tools": "^0.11.0",
36
36
  "@canonmsg/coding-agent-host": "^0.9.0",
37
- "@canonmsg/core": "^13.0.0",
37
+ "@canonmsg/core": "^13.0.3",
38
38
  "@canonmsg/rich-cards": "^0.10.6",
39
39
  "ws": "^8.21.3"
40
40
  },