@canonmsg/codex-plugin 0.31.0 → 0.32.0

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.
Files changed (2) hide show
  1. package/dist/host.js +70 -100
  2. package/package.json +7 -7
package/dist/host.js CHANGED
@@ -5,8 +5,8 @@ import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
- import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } 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, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
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';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
@@ -688,7 +688,8 @@ export async function main() {
688
688
  lockHandle?.release();
689
689
  throw error;
690
690
  }
691
- const client = new CanonClient(apiKey, baseUrl);
691
+ const client = new CanonClient(apiKey, baseUrl, { environmentId: resolvedAgent.environmentId, streamUrl });
692
+ const endpoint = await client.getEndpoint();
692
693
  const rtdb = initRTDBAuth(client, { rtdbUrl, firebaseApiKey });
693
694
  const typingSignals = createTypingStatusPublisher({
694
695
  setTyping: (conversationId, typing, status) => status
@@ -861,6 +862,8 @@ export async function main() {
861
862
  ...payload.changes,
862
863
  memberIds,
863
864
  });
865
+ void client.rememberConversation(conversationCache.get(payload.conversationId))
866
+ .catch((error) => console.error('[canon] Failed to persist conversation update:', error));
864
867
  }
865
868
  if (membershipChange) {
866
869
  pendingMembershipChanges.set(payload.conversationId, membershipChange);
@@ -1787,6 +1790,7 @@ export async function main() {
1787
1790
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1788
1791
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1789
1792
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1793
+ await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1790
1794
  return;
1791
1795
  }
1792
1796
  if (input.message.metadata?.type === 'plan_approval_reply') {
@@ -1795,6 +1799,7 @@ export async function main() {
1795
1799
  // replies left by an older coding descriptor instead of turning them
1796
1800
  // into hidden plan/implementation prompts after an upgrade or restart.
1797
1801
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1802
+ await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1798
1803
  return;
1799
1804
  }
1800
1805
  const planId = readString(input.message.metadata, 'planId');
@@ -1803,6 +1808,7 @@ export async function main() {
1803
1808
  metadata: input.message.metadata,
1804
1809
  })) {
1805
1810
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1811
+ await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1806
1812
  return;
1807
1813
  }
1808
1814
  const session = await getOrCreateSession(input.conversationId);
@@ -1877,6 +1883,7 @@ export async function main() {
1877
1883
  if (!autoReply.allow) {
1878
1884
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1879
1885
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1886
+ await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1880
1887
  return;
1881
1888
  }
1882
1889
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -1907,6 +1914,7 @@ export async function main() {
1907
1914
  ...(input.replyAuthority ? { replyAuthority: input.replyAuthority } : {}),
1908
1915
  }).catch(() => { });
1909
1916
  recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1917
+ await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1910
1918
  return;
1911
1919
  }
1912
1920
  session.activeSelfContextId = activeSelfContextId;
@@ -1963,6 +1971,8 @@ export async function main() {
1963
1971
  if (!nextTurn)
1964
1972
  return;
1965
1973
  session.running = true;
1974
+ const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
1975
+ let journaledInput = false;
1966
1976
  session.state.lastError = undefined;
1967
1977
  session.state.state = 'running';
1968
1978
  session.currentTurnId = randomUUID();
@@ -2180,6 +2190,12 @@ export async function main() {
2180
2190
  skillInvocationText: nextTurn.skillInvocationText,
2181
2191
  onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null, nextTurn.sourceMessageId ?? null),
2182
2192
  });
2193
+ // Canon-origin queues must retain a durable execution owner, including
2194
+ // legacy queues restored before endpoint migration. Native continuations
2195
+ // without a Canon source message remain a separate operator path.
2196
+ if (inboundId && !await endpoint.claimInbound(inboundId))
2197
+ return;
2198
+ journaledInput = !!inboundId;
2183
2199
  let result = await runTurnOnce();
2184
2200
  if (!result.interrupted
2185
2201
  && !result.finalMessage
@@ -2195,6 +2211,8 @@ export async function main() {
2195
2211
  session.currentTurnSilenced = false;
2196
2212
  result = await runTurnOnce();
2197
2213
  }
2214
+ if (journaledInput)
2215
+ await endpoint.setInboundState(inboundId, 'settled', 'native-completed');
2198
2216
  // Both the artifact gate and the final delivery weigh silence against
2199
2217
  // this text, and they must weigh the same one — set it before any
2200
2218
  // completion branch runs, including the ones that route first.
@@ -2359,6 +2377,11 @@ export async function main() {
2359
2377
  }
2360
2378
  }
2361
2379
  catch (error) {
2380
+ if (isPendingCanonOperation(error)) {
2381
+ session.state.lastError = 'Canon delivery remains pending reconciliation.';
2382
+ writeState(session);
2383
+ return;
2384
+ }
2362
2385
  const message = error instanceof ExecutionEnvironmentError
2363
2386
  ? error.userMessage
2364
2387
  : error instanceof CanonApiError
@@ -2653,75 +2676,10 @@ export async function main() {
2653
2676
  console.error(`[canon-codex] Runtime ${operation} failed:`, error);
2654
2677
  },
2655
2678
  });
2656
- let startupRecoveryComplete = false;
2657
2679
  async function recoverInboundMessageGaps() {
2658
- // A reconnect can reveal conversations created while this host was
2659
- // offline. Refresh before sweeping cursors; this is one REST reconciliation
2660
- // pass, not an automatic retry loop.
2661
- const knownBeforeRefresh = new Set(knownConversationIds);
2680
+ // Refresh discovery only. Timeline reconstruction is never authorization to
2681
+ // execute historical turns; admitted pending work lives in the endpoint inbox.
2662
2682
  await refreshKnownConversationIds(true);
2663
- const conversationsDiscoveredWhileOffline = startupRecoveryComplete
2664
- ? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
2665
- : new Set();
2666
- for (const conversationId of knownConversationIds) {
2667
- const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
2668
- const recoveryBatch = recoveryCheckpoints.reserveBatch();
2669
- try {
2670
- const cursor = loadRuntimeSessionState(runtimeId, {
2671
- conversationId,
2672
- baseCwd: workingDir,
2673
- })?.lastInboundMessageId ?? null;
2674
- const recovered = await collectMissedInboundMessages({
2675
- fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
2676
- cursor,
2677
- agentId,
2678
- requireContiguousCursor: true,
2679
- noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
2680
- ? 'bounded-window'
2681
- : 'latest-only',
2682
- });
2683
- recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
2684
- if (recovered.mode === 'incomplete-gap') {
2685
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
2686
- }
2687
- for (const message of recovered.messages) {
2688
- const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
2689
- if (!isPlanReply && !shouldTriggerAgentTurn({
2690
- senderType: message.senderType,
2691
- metadata: message.metadata,
2692
- }).allow) {
2693
- recoveryCheckpoints.settle(message.id);
2694
- continue;
2695
- }
2696
- if (!claimInboundMessageId(message.id))
2697
- continue;
2698
- try {
2699
- await enqueueInboundMessage({
2700
- conversationId,
2701
- message,
2702
- senderName: message.senderName || message.senderId,
2703
- isOwner: message.senderId === ownerId,
2704
- behavior: recovered.newestPage.behavior,
2705
- activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
2706
- selfContexts: recovered.newestPage.selfContexts,
2707
- hydratedPage: recovered.newestPage,
2708
- });
2709
- settleInboundMessageId(message.id, true);
2710
- }
2711
- catch (error) {
2712
- settleInboundMessageId(message.id, false);
2713
- throw error;
2714
- }
2715
- }
2716
- if (recovered.messages.length > 0) {
2717
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
2718
- }
2719
- }
2720
- catch (error) {
2721
- recoveryBatch.cancel();
2722
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery failed:`, error instanceof Error ? error.message : error);
2723
- }
2724
- }
2725
2683
  }
2726
2684
  const reconnectRecovery = createReconnectRecoveryCoordinator(recoverInboundMessageGaps);
2727
2685
  const observeReconnectRecovery = (reason, recovery) => {
@@ -2731,39 +2689,51 @@ export async function main() {
2731
2689
  console.error(`[canon-codex] ${reason} recovery failed:`, error instanceof Error ? error.message : error);
2732
2690
  });
2733
2691
  };
2692
+ const inbound = endpoint.acceptInbound({ kind: 'message.created',
2693
+ offer: async (event) => {
2694
+ const payload = event.data;
2695
+ const message = payload.message;
2696
+ if (message.senderId === agentId) {
2697
+ await endpoint.setInboundState(event.id, 'settled', 'own-message');
2698
+ return;
2699
+ }
2700
+ if (!claimInboundMessageId(message.id))
2701
+ return;
2702
+ recoveryCheckpointsFor(payload.conversationId).track(message.id);
2703
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2704
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2705
+ recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2706
+ settleInboundMessageId(message.id, true);
2707
+ await endpoint.setInboundState(`message:${payload.conversationId}:${message.id}`, 'settled', 'observe-only');
2708
+ return;
2709
+ }
2710
+ await enqueueInboundMessage({
2711
+ conversationId: payload.conversationId,
2712
+ message,
2713
+ senderName: message.senderName || message.senderId,
2714
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2715
+ behavior: payload.behavior,
2716
+ activeSelfContextId: payload.activeSelfContextId,
2717
+ selfContexts: payload.selfContexts,
2718
+ provenance: payload.provenance,
2719
+ turnDispatch: payload.turnDispatch,
2720
+ replyAuthority: payload.replyAuthority,
2721
+ }).then(() => settleInboundMessageId(message.id, true), (error) => {
2722
+ settleInboundMessageId(message.id, false);
2723
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
2724
+ throw error;
2725
+ });
2726
+ },
2727
+ onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
2728
+ inbound.start();
2734
2729
  const stream = new CanonStream({
2735
- apiKey,
2730
+ endpoint,
2736
2731
  agentId,
2737
- streamUrl,
2738
2732
  handler: {
2739
2733
  onMessage: (payload) => {
2740
- const message = payload.message;
2741
- if (message.senderId === agentId)
2742
- return;
2743
- if (!claimInboundMessageId(message.id))
2744
- return;
2745
- recoveryCheckpointsFor(payload.conversationId).track(message.id);
2746
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2747
- console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2748
- recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2749
- settleInboundMessageId(message.id, true);
2750
- return;
2751
- }
2752
- void enqueueInboundMessage({
2753
- conversationId: payload.conversationId,
2754
- message,
2755
- senderName: message.senderName || message.senderId,
2756
- isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2757
- behavior: payload.behavior,
2758
- activeSelfContextId: payload.activeSelfContextId,
2759
- selfContexts: payload.selfContexts,
2760
- provenance: payload.provenance,
2761
- turnDispatch: payload.turnDispatch,
2762
- replyAuthority: payload.replyAuthority,
2763
- }).then(() => settleInboundMessageId(message.id, true), (error) => {
2764
- settleInboundMessageId(message.id, false);
2765
- console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
2766
- });
2734
+ void inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
2735
+ kind: 'message.created', conversationId: payload.conversationId, durable: true,
2736
+ data: payload });
2767
2737
  },
2768
2738
  onMessageDeleted: (payload) => {
2769
2739
  removeQueuedPrompt(payload.conversationId, payload.messageId);
@@ -2809,7 +2779,6 @@ export async function main() {
2809
2779
  await reconnectRecovery.recoverNow().catch((error) => {
2810
2780
  console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
2811
2781
  });
2812
- startupRecoveryComplete = true;
2813
2782
  startCodexStreamInBackground(stream, (error) => {
2814
2783
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2815
2784
  });
@@ -2845,6 +2814,7 @@ export async function main() {
2845
2814
  }
2846
2815
  runtimeRequests.dispose();
2847
2816
  stream.stop();
2817
+ await inbound.close();
2848
2818
  await runtimeHeartbeat.dispose();
2849
2819
  for (const session of [...sessions.values()]) {
2850
2820
  await session.adapter.interrupt().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,15 +31,15 @@
31
31
  "prepack": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@canonmsg/agent-sdk": "^10.4.0",
35
- "@canonmsg/agent-tools": "^0.10.0",
36
- "@canonmsg/coding-agent-host": "^0.8.0",
37
- "@canonmsg/core": "^12.5.0",
38
- "@canonmsg/rich-cards": "^0.10.5",
34
+ "@canonmsg/agent-sdk": "^11.0.0",
35
+ "@canonmsg/agent-tools": "^0.11.0",
36
+ "@canonmsg/coding-agent-host": "^0.9.0",
37
+ "@canonmsg/core": "^13.0.0",
38
+ "@canonmsg/rich-cards": "^0.10.6",
39
39
  "ws": "^8.21.3"
40
40
  },
41
41
  "engines": {
42
- "node": ">=18.0.0"
42
+ "node": ">=22.22.3"
43
43
  },
44
44
  "keywords": [
45
45
  "canon",