@canonmsg/codex-plugin 0.32.0 → 0.32.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.
@@ -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, hydrateCanonReplyContext, 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();
@@ -898,7 +987,7 @@ export async function main() {
898
987
  ? Promise.resolve(input.hydratedPage)
899
988
  : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
900
989
  ]);
901
- return buildHydratedInboundContext({
990
+ const hydrated = buildHydratedInboundContext({
902
991
  agentId,
903
992
  conversationId: input.conversationId,
904
993
  conversation,
@@ -915,6 +1004,15 @@ export async function main() {
915
1004
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
916
1005
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
917
1006
  });
1007
+ return {
1008
+ ...hydrated,
1009
+ replyContext: await hydrateCanonReplyContext({
1010
+ client,
1011
+ conversationId: input.conversationId,
1012
+ message: input.message,
1013
+ messages: page?.messages,
1014
+ }),
1015
+ };
918
1016
  }
919
1017
  function writeState(session) {
920
1018
  runtimeState.writeSessionState(session.conversationId, {
@@ -991,6 +1089,14 @@ export async function main() {
991
1089
  settleRejectedPromptCheckpoints(conversationId, removed);
992
1090
  writeTurn(session);
993
1091
  }
1092
+ async function clearCompletedSilentStreaming(conversationId) {
1093
+ await clearCodexStreamingWithCompletedSilence({
1094
+ records: completedOutputs.list().filter((record) => record.conversationId === conversationId),
1095
+ read: () => rtdb.read(`/streaming/${conversationId}/${agentId}`),
1096
+ blank: (turnId) => runtimeState.writeStreaming(conversationId, { text: '', status: 'thinking', messageId: turnId, turnId, blocks: [] }),
1097
+ clear: () => runtimeState.clearStreaming(conversationId),
1098
+ });
1099
+ }
994
1100
  function clearStreaming(conversationId) {
995
1101
  runtimeState.clearStreaming(conversationId).catch(() => { });
996
1102
  }
@@ -1028,7 +1134,7 @@ export async function main() {
1028
1134
  messageId: session.currentTurnId ?? undefined,
1029
1135
  turnId: session.currentTurnId,
1030
1136
  blocks: [],
1031
- }).catch(() => { });
1137
+ });
1032
1138
  }
1033
1139
  function upsertCodexTextSegment(session, event) {
1034
1140
  const next = applyTextSegmentBlock({
@@ -1091,7 +1197,7 @@ export async function main() {
1091
1197
  }
1092
1198
  async function handoffFinalMessage(conversationId) {
1093
1199
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
1094
- clearStreaming(conversationId);
1200
+ await runtimeState.clearStreaming(conversationId);
1095
1201
  typingSignals.clear(conversationId).catch(() => { });
1096
1202
  }
1097
1203
  function refreshVisibleWorkSignal(session) {
@@ -1239,136 +1345,146 @@ export async function main() {
1239
1345
  workspaceCwd,
1240
1346
  allowWorktrees: sessionExecutionMode === 'worktree',
1241
1347
  });
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,
1348
+ let createdAdapter;
1349
+ return initializeCodexSession({
1350
+ key: conversationId,
1351
+ sessions,
1352
+ create: async () => {
1353
+ const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
1259
1354
  conversationId,
1260
- workspaceCwd: persistedMapping.state.baseCwd,
1261
- allowWorktrees: persistedMapping.state.executionMode === 'worktree',
1355
+ baseCwd: environment.baseCwd,
1356
+ executionMode: environment.mode,
1357
+ resumeField: 'threadId',
1358
+ configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
1359
+ availableExecutionModes: hostAvailableExecutionModes,
1262
1360
  });
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);
1361
+ if (persistedMapping.status === 'configuration_required') {
1362
+ throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
1266
1363
  }
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({
1364
+ if (persistedMapping.status === 'adopted'
1365
+ && (persistedMapping.state.baseCwd !== environment.baseCwd
1366
+ || persistedMapping.state.executionMode !== environment.mode)) {
1367
+ const restoredEnvironment = prepareConversationEnvironment({
1368
+ agentId,
1369
+ conversationId,
1370
+ workspaceCwd: persistedMapping.state.baseCwd,
1371
+ allowWorktrees: persistedMapping.state.executionMode === 'worktree',
1372
+ });
1373
+ if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
1374
+ releaseConversationEnvironment(restoredEnvironment);
1375
+ 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);
1376
+ }
1377
+ releaseConversationEnvironment(environment);
1378
+ environment = restoredEnvironment;
1379
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
1380
+ }
1381
+ const sessionCwd = environment.cwd;
1382
+ const policy = resolveCodexEffectiveRuntimePolicy({
1383
+ args,
1384
+ config: null,
1385
+ permissionEnvelope: codexPermissionEnvelope,
1386
+ environment,
1387
+ serviceAgentMode,
1388
+ });
1389
+ const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
1390
+ if (modelGuard) {
1391
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1392
+ }
1393
+ const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
1394
+ ...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
1395
+ ? {}
1396
+ : { workspaceId: persistedMapping.state.workspaceId }),
1397
+ allowLegacyPolicyMigration: persistedMapping.status !== 'new',
1398
+ });
1399
+ const effectiveModel = policy.model ?? codexDefaultModel;
1400
+ const initialEffortResolution = resolveCodexEffortForModel({
1401
+ models: codexModels,
1402
+ model: effectiveModel,
1403
+ requestedEffort: configuredCodexEffort,
1404
+ });
1405
+ const initialEffort = initialEffortResolution.value;
1406
+ const adapter = useAppServer
1407
+ ? new CodexAppServerAdapter({
1408
+ cwd: sessionCwd,
1409
+ threadId: storedThreadId,
1410
+ codexBin,
1411
+ model: policy.model ?? null,
1412
+ reasoningEffort: initialEffort,
1413
+ sandbox: policy.sandbox,
1414
+ addDirs: args['add-dir'] ?? [],
1415
+ configOverrides: args.config ?? [],
1416
+ fullAuto: policy.fullAuto,
1417
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1418
+ dynamicTools: codexDynamicTools,
1419
+ })
1420
+ : new CodexConversationAdapter({
1421
+ cwd: sessionCwd,
1422
+ threadId: storedThreadId,
1423
+ codexBin,
1424
+ model: policy.model ?? null,
1425
+ reasoningEffort: initialEffort,
1426
+ sandbox: policy.sandbox,
1427
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
1428
+ addDirs: args['add-dir'] ?? [],
1429
+ configOverrides: args.config ?? [],
1430
+ fullAuto: policy.fullAuto,
1431
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1432
+ });
1433
+ createdAdapter = adapter;
1434
+ const session = {
1435
+ conversationId,
1311
1436
  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,
1437
+ environment,
1438
+ adapter,
1439
+ queue: [],
1440
+ running: false,
1441
+ state: buildCodexInitialSessionState({
1442
+ model: effectiveModel ?? undefined,
1443
+ permissionMode: policy.permissionMode,
1444
+ effort: initialEffort,
1445
+ }),
1446
+ policyFingerprint: policy.fingerprint,
1447
+ turnState: 'idle',
1448
+ currentTurnId: null,
1449
+ currentTurnOpenedAt: null,
1450
+ currentTurnUpdatedAt: null,
1451
+ currentTurnCanUseCodexAppTools: false,
1452
+ currentReplyAuthority: null,
1453
+ currentTurnAbortController: null,
1454
+ // Corrected by the first turn that runs; a session with no turn
1455
+ // publishes nothing anyway, and quiet is never an accident.
1456
+ turnVerbosity: 'verbose',
1457
+ currentTurnSilenced: false,
1458
+ activeSelfContextId: null,
1459
+ lastAcceptedIntent: null,
1460
+ resetRequested: false,
1461
+ lastActivity: Date.now(),
1462
+ typingKeepaliveTimer: null,
1463
+ closed: false,
1464
+ turnLiveText: '',
1465
+ turnBlocks: [],
1466
+ turnCommandBlocks: createCommandBlockTracker(),
1467
+ };
1468
+ await controlPoller.baseline([conversationId]);
1469
+ await runtimeState.patchAgentSessionSnapshot(conversationId, {
1470
+ configurationStatus: 'ready',
1471
+ lastError: null,
1322
1472
  });
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
- }
1473
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1474
+ writeState(session);
1475
+ writeTurn(session);
1476
+ return session;
1477
+ },
1478
+ cleanup: () => {
1479
+ try {
1480
+ if (createdAdapter instanceof CodexAppServerAdapter)
1481
+ createdAdapter.close();
1482
+ }
1483
+ finally {
1484
+ releaseConversationEnvironment(environment);
1485
+ }
1486
+ },
1487
+ });
1372
1488
  })();
1373
1489
  pendingSessionCreations.set(conversationId, creation);
1374
1490
  try {
@@ -1897,6 +2013,11 @@ export async function main() {
1897
2013
  catch (error) {
1898
2014
  const message = error instanceof Error ? error.message : String(error);
1899
2015
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
2016
+ // No native input has been claimed or submitted. The endpoint can offer
2017
+ // this input again after a service/storage failure; do not settle it or
2018
+ // misreport a temporary API failure as missing local configuration.
2019
+ if (classifyCodexSessionStart(error) === 'deferred')
2020
+ throw error;
1900
2021
  await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
1901
2022
  configurationStatus: 'configuration_required',
1902
2023
  lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
@@ -1967,12 +2088,36 @@ export async function main() {
1967
2088
  async function runNextTurn(session) {
1968
2089
  if (session.running || session.closed)
1969
2090
  return;
2091
+ // Reserve the room before awaiting recovery so a new turn cannot race an
2092
+ // older completion's streaming teardown. The queue remains unclaimed on
2093
+ // failure and the ordinary host heartbeat retries it.
2094
+ session.running = true;
2095
+ try {
2096
+ await recoveringOutputRooms.get(session.conversationId);
2097
+ for (const record of completedOutputs.list()) {
2098
+ if (record.conversationId === session.conversationId && record.output.kind === 'none' && record.output.reason === 'silent') {
2099
+ await deliverCompletedOutput(record);
2100
+ }
2101
+ }
2102
+ }
2103
+ catch (error) {
2104
+ session.running = false;
2105
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Prior completion still needs cleanup:`, error);
2106
+ return;
2107
+ }
2108
+ if (session.closed || sessions.get(session.conversationId) !== session) {
2109
+ session.running = false;
2110
+ return;
2111
+ }
1970
2112
  const nextTurn = session.queue.shift();
1971
- if (!nextTurn)
2113
+ if (!nextTurn) {
2114
+ session.running = false;
1972
2115
  return;
1973
- session.running = true;
2116
+ }
1974
2117
  const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
1975
2118
  let journaledInput = false;
2119
+ let completedOutput;
2120
+ let nativeCompleted = false;
1976
2121
  session.state.lastError = undefined;
1977
2122
  session.state.state = 'running';
1978
2123
  session.currentTurnId = randomUUID();
@@ -2211,8 +2356,49 @@ export async function main() {
2211
2356
  session.currentTurnSilenced = false;
2212
2357
  result = await runTurnOnce();
2213
2358
  }
2214
- if (journaledInput)
2215
- await endpoint.setInboundState(inboundId, 'settled', 'native-completed');
2359
+ nativeCompleted = true;
2360
+ const turnTrail = buildFinalTurnTrail(session);
2361
+ const deliveryMetadata = {
2362
+ turnId: session.currentTurnId,
2363
+ turnSemantics: 'turn_complete',
2364
+ ...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
2365
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
2366
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
2367
+ };
2368
+ const canDeliver = !result.interrupted && !!result.finalMessage && resolveSilentTurnDelivery({
2369
+ silenced: session.currentTurnSilenced, finalText: result.finalMessage,
2370
+ }) === 'deliver';
2371
+ const responseRouting = buildCodexTurnResponseRouting({ requestingUserId: nextTurn.requestingUserId, ownerId });
2372
+ const output = canDeliver && nextTurn.planMode
2373
+ ? { kind: 'plan', body: {
2374
+ conversationId: session.conversationId, interactionKind: 'plan', planId: session.currentTurnId,
2375
+ expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS, title: 'Codex Plan', body: result.finalMessage, turnId: session.currentTurnId,
2376
+ ...(responseRouting.responseUserId ? { responseUserId: responseRouting.responseUserId } : {}),
2377
+ } }
2378
+ : canDeliver || (!result.interrupted && !!result.exitCode)
2379
+ ? { kind: 'message', text: canDeliver ? result.finalMessage : formatCodexTurnFailure(result.errorText), options: {
2380
+ messageId: buildCodexMessageId(session, canDeliver ? 'final' : 'error'),
2381
+ ...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
2382
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
2383
+ metadata: deliveryMetadata,
2384
+ } }
2385
+ : { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
2386
+ const record = {
2387
+ version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
2388
+ sourceMessageId: journaledInput ? nextTurn.sourceMessageId : null,
2389
+ nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
2390
+ audience: conversationCache.has(session.conversationId) ? {
2391
+ memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
2392
+ ...(conversationCache.get(session.conversationId).membershipRevision !== undefined
2393
+ ? { membershipRevision: conversationCache.get(session.conversationId).membershipRevision } : {}),
2394
+ } : null,
2395
+ };
2396
+ // This is the first local evidence that native work completed. A crash
2397
+ // before this fsynced record remains uncertain and needs its transcript;
2398
+ // after it, recovery only republishes the exact saved Canon output.
2399
+ completedOutputs.save(record);
2400
+ completedOutput = record;
2401
+ activeCompletedOutputs.add(record.turnId);
2216
2402
  // Both the artifact gate and the final delivery weigh silence against
2217
2403
  // this text, and they must weigh the same one — set it before any
2218
2404
  // completion branch runs, including the ones that route first.
@@ -2231,23 +2417,10 @@ export async function main() {
2231
2417
  if (result.threadId && !session.resetRequested) {
2232
2418
  saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
2233
2419
  }
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') {
2420
+ if (completedOutput.output.kind === 'plan') {
2245
2421
  await routeArtifactsOnce();
2246
- const responseRouting = buildCodexTurnResponseRouting({
2247
- requestingUserId: nextTurn.requestingUserId,
2248
- ownerId,
2249
- });
2250
- const planId = session.currentTurnId ?? randomUUID();
2422
+ const plan = completedOutput.output.body;
2423
+ const planId = plan.planId;
2251
2424
  let planCreated = false;
2252
2425
  let resolvePlanCreated;
2253
2426
  let rejectPlanCreated;
@@ -2256,17 +2429,14 @@ export async function main() {
2256
2429
  rejectPlanCreated = reject;
2257
2430
  });
2258
2431
  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 } : {}),
2432
+ title: plan.title, body: plan.body, turnId: plan.turnId,
2433
+ ...(plan.responseUserId ? { responseUserId: plan.responseUserId } : {}),
2265
2434
  }, {
2266
2435
  requestId: planId,
2267
- expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS,
2436
+ expiresAt: plan.expiresAt,
2268
2437
  responderPolicy: 'infer',
2269
- onCreated: () => {
2438
+ onCreated: async () => {
2439
+ await deliverCompletedOutput(completedOutput, true);
2270
2440
  planCreated = true;
2271
2441
  session.turnState = 'waiting_input';
2272
2442
  writeTurn(session);
@@ -2275,7 +2445,7 @@ export async function main() {
2275
2445
  });
2276
2446
  void planReview.then((planResult) => {
2277
2447
  resolvePlanCreated();
2278
- enqueueCodexPlanReviewResult(session, planResult, responseRouting.responseUserId ?? null);
2448
+ enqueueCodexPlanReviewResult(session, planResult, plan.responseUserId ?? null);
2279
2449
  }).catch((error) => {
2280
2450
  if (!planCreated) {
2281
2451
  rejectPlanCreated(error);
@@ -2287,40 +2457,16 @@ export async function main() {
2287
2457
  await handoffFinalMessage(session.conversationId);
2288
2458
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
2289
2459
  }
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') {
2460
+ else if (completedOutput.output.kind === 'message' && canDeliver) {
2299
2461
  if (isRecoverableCodexThreadError(result.errorText)) {
2300
2462
  clearStoredThread();
2301
2463
  }
2302
2464
  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
- });
2465
+ await deliverCompletedOutput(completedOutput);
2320
2466
  await handoffFinalMessage(session.conversationId);
2321
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
2467
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${completedOutput.output.text.length} chars)`);
2322
2468
  }
2323
- else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
2469
+ else if (completedOutput.output.kind === 'message') {
2324
2470
  await routeArtifactsOnce();
2325
2471
  const userVisibleError = formatCodexTurnFailure(result.errorText);
2326
2472
  session.state.lastError = userVisibleError;
@@ -2328,23 +2474,7 @@ export async function main() {
2328
2474
  if (result.errorText) {
2329
2475
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
2330
2476
  }
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
- });
2477
+ await deliverCompletedOutput(completedOutput);
2348
2478
  await handoffFinalMessage(session.conversationId);
2349
2479
  }
2350
2480
  else if (!result.interrupted) {
@@ -2362,7 +2492,7 @@ export async function main() {
2362
2492
  // typing dots go together — leaving dots behind the removed row for
2363
2493
  // the handoff window reads as "started to answer, then gave up".
2364
2494
  await blankCodexStreaming(session);
2365
- clearStreaming(session.conversationId);
2495
+ await runtimeState.clearStreaming(session.conversationId);
2366
2496
  stopVisibleWorkSignal(session);
2367
2497
  }
2368
2498
  await handoffFinalMessage(session.conversationId);
@@ -2375,8 +2505,18 @@ export async function main() {
2375
2505
  typingSignals.clear(session.conversationId).catch(() => { });
2376
2506
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
2377
2507
  }
2508
+ if (completedOutput.output.kind === 'none')
2509
+ await deliverCompletedOutput(completedOutput);
2378
2510
  }
2379
2511
  catch (error) {
2512
+ if (nativeCompleted) {
2513
+ session.state.lastError = completedOutput
2514
+ ? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
2515
+ : 'Native work completed, but its output could not be journaled. Operator reconciliation is required.';
2516
+ writeState(session);
2517
+ console.error(`[canon-codex] Native completion ${session.currentTurnId} needs reconciliation:`, error);
2518
+ return;
2519
+ }
2380
2520
  if (isPendingCanonOperation(error)) {
2381
2521
  session.state.lastError = 'Canon delivery remains pending reconciliation.';
2382
2522
  writeState(session);
@@ -2415,6 +2555,8 @@ export async function main() {
2415
2555
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
2416
2556
  }
2417
2557
  finally {
2558
+ if (completedOutput)
2559
+ activeCompletedOutputs.delete(completedOutput.turnId);
2418
2560
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2419
2561
  session.currentTurnAbortController = null;
2420
2562
  recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
@@ -2725,7 +2867,6 @@ export async function main() {
2725
2867
  });
2726
2868
  },
2727
2869
  onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
2728
- inbound.start();
2729
2870
  const stream = new CanonStream({
2730
2871
  endpoint,
2731
2872
  agentId,
@@ -2768,7 +2909,8 @@ export async function main() {
2768
2909
  for (const conversation of conversations) {
2769
2910
  knownConversationIds.add(conversation.id);
2770
2911
  conversationCache.set(conversation.id, conversation);
2771
- clearStreaming(conversation.id);
2912
+ await clearCompletedSilentStreaming(conversation.id);
2913
+ await runtimeState.clearStreaming(conversation.id);
2772
2914
  runtimeState.clearSessionState(conversation.id).catch(() => { });
2773
2915
  runtimeState.clearTurnState(conversation.id).catch(() => { });
2774
2916
  }
@@ -2779,15 +2921,20 @@ export async function main() {
2779
2921
  await reconnectRecovery.recoverNow().catch((error) => {
2780
2922
  console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2781
2923
  });
2924
+ void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
2925
+ inbound.start();
2782
2926
  startCodexStreamInBackground(stream, (error) => {
2783
2927
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2784
2928
  });
2785
2929
  controlPoller.start();
2786
2930
  const heartbeat = setInterval(() => {
2931
+ void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
2787
2932
  for (const session of sessions.values()) {
2788
2933
  writeState(session);
2789
2934
  if (!session.running) {
2790
2935
  writeTurn(session);
2936
+ if (session.queue.length)
2937
+ void runNextTurn(session);
2791
2938
  }
2792
2939
  }
2793
2940
  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.2",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,10 +31,10 @@
31
31
  "prepack": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@canonmsg/agent-sdk": "^11.0.0",
34
+ "@canonmsg/agent-sdk": "^11.0.1",
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.4",
38
38
  "@canonmsg/rich-cards": "^0.10.6",
39
39
  "ws": "^8.21.3"
40
40
  },