@canonmsg/codex-plugin 0.24.0 → 0.25.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.
package/dist/host.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode } from '@canonmsg/coding-agent-host';
2
3
  import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type WorkspaceOption, type CanonWorkspaceRootMetadata, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
3
4
  import { type CodexSkillMetadata } from './app-server-adapter.js';
4
5
  import { type CodexControlOption } from './model-catalog.js';
@@ -136,5 +137,33 @@ export declare function planCodexStreamingWrite(input: {
136
137
  * same decision at its `text_delta` handler.
137
138
  */
138
139
  export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
140
+ /**
141
+ * Whether this turn may post the media it generated in the workspace.
142
+ *
143
+ * Where the shared ruling (`resolveTurnArtifactRouting`) meets this host's
144
+ * per-turn state, and the only place silence is resolved for Codex artifacts:
145
+ * `silenced` here is the RAW `no_reply` sentinel, and it is weighed against the
146
+ * turn's own final text by `isSilentTurnSuppressed` — the same switch the final
147
+ * delivery reads. Flipping `DEFAULT_SILENT_TURN_PRECEDENCE` to `advisory`
148
+ * therefore moves a turn's text and its files together; it cannot leave Codex
149
+ * talking about a chart it then withholds.
150
+ *
151
+ * `finalText` is the model's own reply, never Canon's failure notice: the
152
+ * notice is a host diagnostic and goes out whether the turn spoke or not.
153
+ *
154
+ * Interruption is not a parameter. Codex's `result.interrupted` branch is the
155
+ * one completion branch that never calls the funnel, so the axis is dead on
156
+ * the normal paths. The known gap is the catch branch, which routes
157
+ * unconditionally and IS reachable after an interrupt (a hard interrupt can
158
+ * make `runTurn` reject rather than resolve); an interrupted turn's artifacts
159
+ * can still be posted alongside the failure notice there, as they always
160
+ * could. Claude gates that case; matching it needs an interrupt signal Codex
161
+ * does not currently carry into the catch without a race.
162
+ */
163
+ export declare function shouldRouteCodexTurnArtifacts(input: {
164
+ artifactRoutingMode: TurnArtifactRoutingMode | undefined;
165
+ silenced: boolean;
166
+ finalText: string | null | undefined;
167
+ }): TurnArtifactRoutingDecision;
139
168
  export declare function main(): Promise<void>;
140
169
  export {};
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, collectTurnArtifacts, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, 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, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
+ import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, 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, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { CodexConversationAdapter, } from './adapter.js';
11
11
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
12
12
  import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, classifyCodexAppToolRequest, codexNoReplyToolResult, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, readCodexNoReplyReason, } from './codex-app-tools.js';
@@ -538,6 +538,35 @@ export function planCodexStreamingWrite(input) {
538
538
  export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
539
539
  return turnVerbosity !== 'quiet';
540
540
  }
541
+ /**
542
+ * Whether this turn may post the media it generated in the workspace.
543
+ *
544
+ * Where the shared ruling (`resolveTurnArtifactRouting`) meets this host's
545
+ * per-turn state, and the only place silence is resolved for Codex artifacts:
546
+ * `silenced` here is the RAW `no_reply` sentinel, and it is weighed against the
547
+ * turn's own final text by `isSilentTurnSuppressed` — the same switch the final
548
+ * delivery reads. Flipping `DEFAULT_SILENT_TURN_PRECEDENCE` to `advisory`
549
+ * therefore moves a turn's text and its files together; it cannot leave Codex
550
+ * talking about a chart it then withholds.
551
+ *
552
+ * `finalText` is the model's own reply, never Canon's failure notice: the
553
+ * notice is a host diagnostic and goes out whether the turn spoke or not.
554
+ *
555
+ * Interruption is not a parameter. Codex's `result.interrupted` branch is the
556
+ * one completion branch that never calls the funnel, so the axis is dead on
557
+ * the normal paths. The known gap is the catch branch, which routes
558
+ * unconditionally and IS reachable after an interrupt (a hard interrupt can
559
+ * make `runTurn` reject rather than resolve); an interrupted turn's artifacts
560
+ * can still be posted alongside the failure notice there, as they always
561
+ * could. Claude gates that case; matching it needs an interrupt signal Codex
562
+ * does not currently carry into the catch without a race.
563
+ */
564
+ export function shouldRouteCodexTurnArtifacts(input) {
565
+ return resolveTurnArtifactRouting({
566
+ artifactRoutingMode: input.artifactRoutingMode,
567
+ silenced: isSilentTurnSuppressed({ silenced: input.silenced, finalText: input.finalText }),
568
+ });
569
+ }
541
570
  export async function main() {
542
571
  setDefaultResultOrder('ipv4first');
543
572
  const { values: args } = parseArgs({
@@ -1683,34 +1712,6 @@ export async function main() {
1683
1712
  },
1684
1713
  });
1685
1714
  }
1686
- async function routeWorkspaceGeneratedArtifacts(session, baseline) {
1687
- if (!baseline)
1688
- return;
1689
- const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
1690
- try {
1691
- const result = await collectTurnArtifacts({
1692
- cwd: session.cwd,
1693
- baseline,
1694
- });
1695
- for (const file of result.files) {
1696
- try {
1697
- const { messageId } = await sendTurnArtifactFile(session, file);
1698
- console.error(`${logPrefix} Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
1699
- }
1700
- catch (error) {
1701
- console.error(`${logPrefix} Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
1702
- }
1703
- }
1704
- for (const skipped of result.skipped) {
1705
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
1706
- console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1707
- }
1708
- }
1709
- }
1710
- catch (error) {
1711
- console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1712
- }
1713
- }
1714
1715
  async function runNextTurn(session) {
1715
1716
  if (session.running || session.closed)
1716
1717
  return;
@@ -1757,15 +1758,27 @@ export async function main() {
1757
1758
  writeCodexStreaming(session, '', 'thinking');
1758
1759
  }
1759
1760
  let artifactBaseline = null;
1760
- let artifactsRouted = false;
1761
- const routeArtifactsOnce = async () => {
1762
- if (artifactsRouted)
1763
- return;
1764
- artifactsRouted = true;
1765
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1766
- await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
1767
- }
1768
- };
1761
+ // The model's own reply for this turn, once it is known. Read by the gate
1762
+ // below so silence is weighed against the SAME text the final delivery
1763
+ // weighs it against; null while the turn runs and after a throw, which is
1764
+ // the honest answer — no reply was produced.
1765
+ let turnFinalText = null;
1766
+ // Every completion branch below funnels through this router, and it owns
1767
+ // the collect-and-post loop, so there is no un-gated path left to the
1768
+ // workspace: the failure branches still send Canon's notice, and none of
1769
+ // them can post the artifacts of a turn that chose silence.
1770
+ const artifactRouter = createTurnArtifactRouter({
1771
+ decide: () => shouldRouteCodexTurnArtifacts({
1772
+ artifactRoutingMode: nextTurn.artifactRoutingMode,
1773
+ silenced: session.currentTurnSilenced,
1774
+ finalText: turnFinalText,
1775
+ }),
1776
+ baseline: () => artifactBaseline,
1777
+ cwd: () => session.cwd,
1778
+ send: (file) => sendTurnArtifactFile(session, file),
1779
+ log: (line) => console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`),
1780
+ });
1781
+ const routeArtifactsOnce = artifactRouter.route;
1769
1782
  try {
1770
1783
  const turnId = session.currentTurnId ?? randomUUID();
1771
1784
  session.currentTurnId = turnId;
@@ -1928,6 +1941,10 @@ export async function main() {
1928
1941
  session.currentTurnSilenced = false;
1929
1942
  result = await runTurnOnce();
1930
1943
  }
1944
+ // Both the artifact gate and the final delivery weigh silence against
1945
+ // this text, and they must weigh the same one — set it before any
1946
+ // completion branch runs, including the ones that route first.
1947
+ turnFinalText = result.finalMessage ?? null;
1931
1948
  if (session.adapter instanceof CodexAppServerAdapter) {
1932
1949
  const resolvedModel = session.adapter.getResolvedModel();
1933
1950
  const resolvedEffort = session.adapter.getResolvedReasoningEffort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,8 +30,8 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@canonmsg/agent-sdk": "^8.2.0",
33
- "@canonmsg/coding-agent-host": "^0.4.0",
34
- "@canonmsg/core": "^10.0.0"
33
+ "@canonmsg/coding-agent-host": "^0.5.0",
34
+ "@canonmsg/core": "^10.1.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"