@canonmsg/codex-plugin 0.23.4 → 0.23.6

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/README.md CHANGED
@@ -14,6 +14,7 @@ npm install -g @canonmsg/codex-plugin
14
14
  codex login status
15
15
 
16
16
  # Register (approve in Canon when prompted)
17
+ export CANON_ENVIRONMENT_ID=canon-prod-v1
17
18
  canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"
18
19
 
19
20
  # Run inside a project and keep the host process running
@@ -39,30 +40,51 @@ canon-necromance revive my-codex
39
40
 
40
41
  Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`.
41
42
 
43
+ `canon-codex-setup` reprints these steps and the optional flags at any time.
44
+
42
45
  Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts: <https://canonmail.com/agents/coding-agents>.
43
46
 
44
- You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
47
+ You do not need a git repo for host mode. Any readable working directory is valid.
45
48
 
46
- ## What v1 supports
49
+ ## What the host supports
47
50
 
48
51
  - Canon messages routed into Codex turns
49
- - One Codex thread per Canon conversation
50
- - Resume by thread ID across turns
52
+ - One Codex thread per Canon conversation, resumed by thread ID across turns
53
+ - Queued input while a turn is running
51
54
  - RTDB session state and activity heartbeat
52
55
  - Interrupt by terminating the active Codex turn
53
56
  - Tool/running status surfaced while Codex is working
57
+ - Reasoning-effort selection, resolved against what the active model accepts
58
+
59
+ ## Transports
60
+
61
+ The host picks a transport at startup and logs the choice as
62
+ `Codex transport: app-server` or `Codex transport: exec --json`.
54
63
 
55
- ## Current limitation
64
+ `codex app-server` is preferred and is selected when `codex app-server --help`
65
+ succeeds. On it Canon additionally routes native plan mode and plan review,
66
+ compact, rich cards, blocking approval gates, runtime questions, live message
67
+ deltas, and model/effort discovery from the runtime.
56
68
 
57
- The stable `codex exec --json` surface exposes thinking state, tool activity, and completed assistant-message previews, but not token-by-token text deltas. v1 therefore publishes live progress and assistant-message snapshots without claiming true token streaming.
69
+ `codex exec --json` is the fallback for older Codex CLIs. It reports thinking
70
+ state, tool activity, and completed assistant-message previews, but not
71
+ token-by-token deltas, and it cannot block on approvals — Canon labels the
72
+ session with that warning rather than implying a gate it does not have. Without
73
+ discovery it also offers no model picker and a fixed effort list.
58
74
 
59
- Current Canon control truth for Codex host mode:
75
+ Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
76
+ the probe when it misfires.
60
77
 
61
- - model is live-visible, but current changes apply on the next turn rather than mid-turn
78
+ Canon control truth for Codex host mode:
79
+
80
+ - model is live-visible, but changes apply on the next turn rather than mid-turn
81
+ - reasoning effort is live-editable and also applies on the next turn; the option list comes from the active model
62
82
  - workspace selection is setup-only
63
83
  - execution mode selection is setup-only
64
- - the current `Execution policy`/permission choice is setup-only
65
- - advanced Codex-only controls such as effort, sandbox policy, approval reviewer, or apps/plugins inventory are not exposed on the current transport unless the runtime can actually report them
84
+ - the `Execution policy`/permission choice is session-creation-only; mid-session change requests are ignored and the applied value is re-published
85
+
86
+ The runtime descriptor the host publishes is the authoritative capability list;
87
+ what Canon renders always comes from it, not from this file.
66
88
 
67
89
  ## Working directory
68
90
 
@@ -85,14 +107,23 @@ Worktree mode is project isolation, not an operating-system sandbox. The Codex C
85
107
  Useful flags:
86
108
 
87
109
  ```bash
88
- canon-codex --cwd /path/to/project --model gpt-5.5 --full-auto
110
+ canon-codex --cwd /path/to/project --model <model-id> --full-auto
89
111
  ```
90
112
 
91
- Codex also supports `--add-dir /extra/path` for additional writable directories passed through to `codex exec`. Canon does not yet render those extra directories as workspace choices.
113
+ Canon hardcodes no model IDs: on the app-server transport the picker is built
114
+ from what the local Codex runtime reports, and `--model` pins the default. A
115
+ model your installed Codex CLI does not know fails early with an upgrade
116
+ message.
92
117
 
93
- `gpt-5.5` requires a recent local Codex CLI. Older CLIs fail early with an upgrade message; upgrade Codex or choose a model supported by your installed CLI.
118
+ `--add-dir /extra/path` adds a writable directory: the exec transport passes it
119
+ through to `codex exec`, the app-server transport adds it to the thread's
120
+ writable roots. Canon does not render those extra directories as workspace
121
+ choices.
94
122
 
95
- Recent Codex CLI releases no longer accept `--ask-for-approval` with `codex exec`. If you previously launched Canon with `--sandbox workspace-write --ask-for-approval never`, switch to `--full-auto`.
123
+ `--ask-for-approval` is rejected by Canon itself the host exits with an error
124
+ pointing at `--full-auto`, `--sandbox`, or Canon permission modes. If you
125
+ previously launched with `--sandbox workspace-write --ask-for-approval never`,
126
+ switch to `--full-auto`.
96
127
 
97
128
  Do not start Canon with `--sandbox danger-full-access` as an unlabeled default. Use `--dangerously-bypass-approvals-and-sandbox` only when you intentionally want Canon to advertise the owner-only Bypass policy.
98
129
 
@@ -125,7 +156,7 @@ canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
125
156
  If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Reconnect the same profile to overwrite `~/.canon/agents.json`, then restart or revive the host:
126
157
 
127
158
  ```bash
128
- canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
159
+ CANON_ENVIRONMENT_ID=canon-prod-v1 canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
129
160
  ```
130
161
 
131
162
  If Codex reports API-key quota errors while another local tool on the same machine uses OpenAI API keys, check Codex's own stored login state:
@@ -147,10 +178,13 @@ Restart `canon-codex` after changing the Codex login state.
147
178
  ## Multiple agents
148
179
 
149
180
  ```bash
181
+ export CANON_ENVIRONMENT_ID=canon-prod-v1
150
182
  canon-codex-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
151
183
  CANON_AGENT=frontend canon-codex --cwd ~/projects/frontend
152
184
  ```
153
185
 
186
+ With more than one registered profile, `CANON_AGENT` is required — the host refuses to guess and lists the available profiles instead.
187
+
154
188
  ## Development
155
189
 
156
190
  ```bash
package/dist/adapter.d.ts CHANGED
@@ -61,7 +61,6 @@ export declare class CodexConversationAdapter {
61
61
  private model;
62
62
  private reasoningEffort;
63
63
  private readonly sandbox;
64
- private readonly legacyApprovalPolicy;
65
64
  private readonly codexProfile;
66
65
  private readonly addDirs;
67
66
  private readonly configOverrides;
@@ -78,7 +77,6 @@ export declare class CodexConversationAdapter {
78
77
  model?: string | null;
79
78
  reasoningEffort?: string | null;
80
79
  sandbox?: CodexSandboxMode | null;
81
- approvalPolicy?: CodexApprovalPolicy | null;
82
80
  codexProfile?: string | null;
83
81
  addDirs?: string[];
84
82
  configOverrides?: string[];
package/dist/adapter.js CHANGED
@@ -7,7 +7,6 @@ export class CodexConversationAdapter {
7
7
  model;
8
8
  reasoningEffort;
9
9
  sandbox;
10
- legacyApprovalPolicy;
11
10
  codexProfile;
12
11
  addDirs;
13
12
  configOverrides;
@@ -24,7 +23,6 @@ export class CodexConversationAdapter {
24
23
  this.model = opts.model ?? null;
25
24
  this.reasoningEffort = opts.reasoningEffort ?? null;
26
25
  this.sandbox = opts.sandbox ?? null;
27
- this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
28
26
  this.codexProfile = opts.codexProfile ?? null;
29
27
  this.addDirs = opts.addDirs ?? [];
30
28
  this.configOverrides = opts.configOverrides ?? [];
@@ -1,4 +1,4 @@
1
- import type { CodexApprovalPolicy, CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
1
+ import type { CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
2
2
  export type JsonRecord = Record<string, unknown>;
3
3
  export interface CodexSkillMetadata {
4
4
  name: string;
@@ -27,7 +27,6 @@ export declare class CodexAppServerAdapter {
27
27
  private model;
28
28
  private reasoningEffort;
29
29
  private readonly sandbox;
30
- private readonly legacyApprovalPolicy;
31
30
  private readonly addDirs;
32
31
  private readonly configOverrides;
33
32
  private readonly fullAuto;
@@ -60,7 +59,6 @@ export declare class CodexAppServerAdapter {
60
59
  model?: string | null;
61
60
  reasoningEffort?: string | null;
62
61
  sandbox?: CodexSandboxMode | null;
63
- approvalPolicy?: CodexApprovalPolicy | null;
64
62
  addDirs?: string[];
65
63
  configOverrides?: string[];
66
64
  fullAuto?: boolean;
@@ -8,7 +8,6 @@ export class CodexAppServerAdapter {
8
8
  model;
9
9
  reasoningEffort;
10
10
  sandbox;
11
- legacyApprovalPolicy;
12
11
  addDirs;
13
12
  configOverrides;
14
13
  fullAuto;
@@ -42,7 +41,6 @@ export class CodexAppServerAdapter {
42
41
  this.reasoningEffort = opts.reasoningEffort ?? null;
43
42
  this.resolvedReasoningEffort = this.reasoningEffort;
44
43
  this.sandbox = opts.sandbox ?? null;
45
- this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
46
44
  this.addDirs = opts.addDirs ?? [];
47
45
  this.configOverrides = opts.configOverrides ?? [];
48
46
  this.fullAuto = opts.fullAuto ?? false;
@@ -198,9 +196,7 @@ export class CodexAppServerAdapter {
198
196
  }
199
197
  }
200
198
  resolveApprovalPolicy() {
201
- if (this.bypassApprovalsAndSandbox || this.fullAuto)
202
- return 'never';
203
- return this.legacyApprovalPolicy;
199
+ return (this.bypassApprovalsAndSandbox || this.fullAuto) ? 'never' : null;
204
200
  }
205
201
  configPayload() {
206
202
  const config = {};
package/dist/host.js CHANGED
@@ -5,9 +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, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
- import { decideAutoReply, } from './inbound-policy.js';
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, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
11
10
  import { CodexConversationAdapter, } from './adapter.js';
12
11
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
13
12
  import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
@@ -18,8 +17,7 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
18
17
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
19
18
  import { startCodexStreamInBackground } from './host-lifecycle.js';
20
19
  import { createCodexControlPoller } from './control-channel.js';
21
- import { runCli } from './cli-entry.js';
22
- import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
20
+ import { runCli } from '@canonmsg/core';
23
21
  import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
24
22
  import { FALLBACK_CODEX_EFFORT_OPTIONS, buildCodexEffortOptions, buildCodexModelOptions, readCodexConfiguredEffort, resolveCodexDefaultModel, resolveCodexEffortForModel, } from './model-catalog.js';
25
23
  const HELP = `canon-codex — run a local Codex agent host for Canon
@@ -240,13 +238,14 @@ export function buildCodexTurnResponseRouting(input) {
240
238
  export function getCodexRequestingUserId(message) {
241
239
  return message.senderType === 'human' ? message.senderId : null;
242
240
  }
243
- async function publishAgentRuntime(agentId, runtime) {
244
- await publishHostAgentRuntime(agentId, 'codex', runtime);
241
+ async function publishAgentRuntime(agentId, runtime, rtdb) {
242
+ await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
245
243
  }
246
- async function loadSessionConfig(conversationId, agentId) {
244
+ async function loadSessionConfig(conversationId, agentId, rtdb) {
247
245
  return loadHostSessionConfig({
248
246
  conversationId,
249
247
  agentId,
248
+ rtdb,
250
249
  extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
251
250
  });
252
251
  }
@@ -286,9 +285,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
286
285
  }
287
286
  const approvalOverride = mapCanonPermissionToCodex(permissionMode);
288
287
  const defaultSandbox = (stringArg(input.args, 'sandbox') ?? null);
289
- const defaultApprovalPolicy = (stringArg(input.args, 'ask-for-approval') ?? null);
290
288
  const sandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
291
- const approvalPolicy = approvalOverride ? null : defaultApprovalPolicy;
292
289
  const fullAuto = approvalOverride ? approvalOverride.fullAuto : boolArg(input.args, 'full-auto');
293
290
  const bypassApprovalsAndSandbox = approvalOverride
294
291
  ? approvalOverride.bypassApprovalsAndSandbox
@@ -298,7 +295,9 @@ function resolveCodexEffectiveRuntimePolicy(input) {
298
295
  executionMode: input.environment.mode,
299
296
  permissionMode: permissionMode ?? null,
300
297
  sandbox,
301
- approvalPolicy,
298
+ // `--ask-for-approval` is rejected at startup, so this was always null.
299
+ // The key must stay: it is hashed into every persisted thread fingerprint.
300
+ approvalPolicy: null,
302
301
  fullAuto,
303
302
  bypassApprovalsAndSandbox,
304
303
  });
@@ -306,7 +305,6 @@ function resolveCodexEffectiveRuntimePolicy(input) {
306
305
  ...(model ? { model } : {}),
307
306
  ...(permissionMode ? { permissionMode } : {}),
308
307
  sandbox,
309
- approvalPolicy,
310
308
  fullAuto,
311
309
  bypassApprovalsAndSandbox,
312
310
  fingerprint,
@@ -620,6 +618,7 @@ export async function main() {
620
618
  agentId,
621
619
  clientType: 'codex',
622
620
  hostMode: true,
621
+ rtdb,
623
622
  });
624
623
  const sessions = new Map();
625
624
  const pendingSessionCreations = new Map();
@@ -939,7 +938,7 @@ export async function main() {
939
938
  const droppedPrompts = session.queue.splice(0);
940
939
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
941
940
  rememberDroppedRecoveryCursor(session, droppedPrompts);
942
- clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
941
+ clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
943
942
  session.adapter.clearThreadId();
944
943
  session.activeSelfContextId = null;
945
944
  session.state.lastError = undefined;
@@ -989,7 +988,7 @@ export async function main() {
989
988
  evictOldestIdle();
990
989
  }
991
990
  const creation = (async () => {
992
- const config = await loadSessionConfig(conversationId, agentId);
991
+ const config = await loadSessionConfig(conversationId, agentId, rtdb);
993
992
  const sessionExecutionMode = resolveSessionExecutionMode(config);
994
993
  const workspaceCwd = resolveWorkspaceCwd(config);
995
994
  const environment = prepareConversationEnvironment({
@@ -1010,7 +1009,7 @@ export async function main() {
1010
1009
  if (modelGuard) {
1011
1010
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1012
1011
  }
1013
- const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
1012
+ const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
1014
1013
  const effectiveModel = policy.model ?? codexDefaultModel;
1015
1014
  const initialEffortResolution = resolveCodexEffortForModel({
1016
1015
  models: codexModels,
@@ -1026,7 +1025,6 @@ export async function main() {
1026
1025
  model: policy.model ?? null,
1027
1026
  reasoningEffort: initialEffort,
1028
1027
  sandbox: policy.sandbox,
1029
- approvalPolicy: policy.approvalPolicy,
1030
1028
  addDirs: args['add-dir'] ?? [],
1031
1029
  configOverrides: args.config ?? [],
1032
1030
  fullAuto: policy.fullAuto,
@@ -1040,7 +1038,6 @@ export async function main() {
1040
1038
  model: policy.model ?? null,
1041
1039
  reasoningEffort: initialEffort,
1042
1040
  sandbox: policy.sandbox,
1043
- approvalPolicy: policy.approvalPolicy,
1044
1041
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
1045
1042
  addDirs: args['add-dir'] ?? [],
1046
1043
  configOverrides: args.config ?? [],
@@ -1565,7 +1562,7 @@ export async function main() {
1565
1562
  if (session.resetRequested) {
1566
1563
  return;
1567
1564
  }
1568
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
1565
+ saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
1569
1566
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
1570
1567
  return;
1571
1568
  }
@@ -1669,7 +1666,7 @@ export async function main() {
1669
1666
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
1670
1667
  };
1671
1668
  const clearStoredThread = () => {
1672
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1669
+ clearStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1673
1670
  session.adapter.clearThreadId();
1674
1671
  };
1675
1672
  const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
@@ -1698,7 +1695,7 @@ export async function main() {
1698
1695
  }
1699
1696
  }
1700
1697
  if (result.threadId && !session.resetRequested) {
1701
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1698
+ saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1702
1699
  }
1703
1700
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1704
1701
  await routeArtifactsOnce();
@@ -1831,7 +1828,7 @@ export async function main() {
1831
1828
  }).catch(() => { });
1832
1829
  await handoffFinalMessage(session.conversationId);
1833
1830
  if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1834
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1831
+ clearStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1835
1832
  }
1836
1833
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1837
1834
  }
@@ -2109,7 +2106,7 @@ export async function main() {
2109
2106
  });
2110
2107
  if (!streamConnected)
2111
2108
  return;
2112
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
2109
+ await publishAgentRuntime(agentId, runtimeDescriptor, rtdb).catch((error) => {
2113
2110
  console.error('[canon-codex] Failed to publish agent runtime:', error);
2114
2111
  });
2115
2112
  if (publishRuntimeDetailsInFlight)
@@ -2122,6 +2119,7 @@ export async function main() {
2122
2119
  await publishHostSessionSnapshots({
2123
2120
  conversationIds: Array.from(knownConversationIds),
2124
2121
  agentId,
2122
+ rtdb,
2125
2123
  clientType: 'codex',
2126
2124
  runtime: runtimeDescriptor,
2127
2125
  workspaceOptions,
package/dist/register.js CHANGED
@@ -1,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { setDefaultResultOrder } from 'node:dns';
3
- import { parseArgs } from 'node:util';
4
- import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, resolveCanonRuntimeConnection, loadProfiles, updatePendingRegistration, upsertAgentProfile, verifyCanonRuntimeConnection, } from '@canonmsg/core';
5
- import { runCli } from './cli-entry.js';
2
+ import { registerRegistrationCli, runRegistrationCli, } from '@canonmsg/core';
6
3
  const HELP = `canon-codex-register — register or reconnect a Codex agent in Canon
7
4
 
8
5
  USAGE
@@ -28,113 +25,17 @@ EXAMPLES
28
25
  canon-codex-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
29
26
 
30
27
  After approval, start it with CANON_AGENT=<profile> canon-codex --cwd /path/to/project.`;
28
+ const OPTIONS = {
29
+ moduleUrl: import.meta.url,
30
+ clientType: 'codex',
31
+ cliName: 'canon-codex-register',
32
+ hostBinName: 'canon-codex',
33
+ developerInfo: 'Codex host plugin',
34
+ registeringLabel: 'Codex agent',
35
+ usage: 'Usage: canon-codex-register --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]',
36
+ help: HELP,
37
+ };
31
38
  export async function main() {
32
- setDefaultResultOrder('ipv4first');
33
- const { values } = parseArgs({
34
- options: {
35
- name: { type: 'string' },
36
- description: { type: 'string' },
37
- phone: { type: 'string' },
38
- profile: { type: 'string' },
39
- environment: { type: 'string' },
40
- 'base-url': { type: 'string' },
41
- 'stream-url': { type: 'string' },
42
- 'rtdb-url': { type: 'string' },
43
- 'firebase-api-key': { type: 'string' },
44
- },
45
- strict: true,
46
- });
47
- if (!values.name || !values.description || !values.phone) {
48
- console.error('Usage: canon-codex-register --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]');
49
- process.exit(1);
50
- }
51
- const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
52
- const environmentId = values.environment || process.env.CANON_ENVIRONMENT_ID;
53
- if (!environmentId) {
54
- throw new Error('--environment or CANON_ENVIRONMENT_ID is required');
55
- }
56
- const connection = resolveCanonRuntimeConnection({
57
- environmentId,
58
- apiBaseUrl: values['base-url'],
59
- streamUrl: values['stream-url'],
60
- rtdbUrl: values['rtdb-url'],
61
- firebaseWebApiKey: values['firebase-api-key'],
62
- });
63
- await verifyCanonRuntimeConnection(connection);
64
- const existingProfile = loadProfiles()[profileName];
65
- const existingAgentId = existingProfile?.environmentId === connection.environmentId
66
- ? existingProfile.agentId
67
- : undefined;
68
- console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
69
- const pending = getOrCreatePendingRegistration(profileName, 'codex', connection);
70
- const result = await registerAndWaitForApproval({
71
- name: values.name,
72
- description: values.description,
73
- ownerPhone: values.phone,
74
- developerInfo: 'Codex host plugin',
75
- clientType: 'codex',
76
- baseUrl: connection.apiBaseUrl,
77
- requestedAgentId: existingAgentId,
78
- localRegistrationId: pending.localRegistrationId,
79
- }, {
80
- onSubmitted: (requestId, pollToken) => {
81
- updatePendingRegistration(profileName, {
82
- requestId,
83
- pollToken,
84
- clientType: 'codex',
85
- });
86
- console.log(`Registration submitted (request ID: ${requestId}).`);
87
- console.log('Waiting for approval in Canon app...');
88
- },
89
- onPollUpdate: () => {
90
- process.stdout.write('.');
91
- },
92
- });
93
- console.log('');
94
- switch (result.status) {
95
- case 'approved': {
96
- if (!result.apiKey || !result.agentId || !result.agentName) {
97
- console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
98
- process.exit(1);
99
- }
100
- upsertAgentProfile(profileName, {
101
- apiKey: result.apiKey,
102
- agentId: result.agentId,
103
- agentName: result.agentName,
104
- registeredAt: new Date().toISOString(),
105
- environmentId: connection.environmentId,
106
- baseUrl: connection.apiBaseUrl,
107
- streamUrl: connection.streamUrl,
108
- rtdbUrl: connection.rtdbUrl,
109
- firebaseApiKey: connection.firebaseWebApiKey,
110
- clientType: 'codex',
111
- });
112
- if (result.requestId) {
113
- await ackRegistrationApproval(connection.apiBaseUrl, result.requestId, result.pollToken);
114
- }
115
- clearPendingRegistration(profileName);
116
- console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
117
- console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
118
- console.log('Start it with: CANON_AGENT=' + profileName + ' canon-codex --cwd /path/to/project');
119
- console.log('Keep that terminal open while you want Canon to reach the agent.');
120
- console.log('Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.');
121
- console.log('Docs: https://canonmail.com/agents/integrations');
122
- break;
123
- }
124
- case 'rejected':
125
- console.log('Registration was rejected.');
126
- process.exit(1);
127
- break;
128
- case 'timeout':
129
- console.log('Registration timed out (5 minutes). Try again later.');
130
- process.exit(1);
131
- break;
132
- }
39
+ await runRegistrationCli(OPTIONS);
133
40
  }
134
- runCli(import.meta.url, main, (error) => {
135
- console.error('[canon-codex-register] Fatal error:', error);
136
- process.exit(1);
137
- }, {
138
- name: 'canon-codex-register',
139
- help: HELP,
140
- });
41
+ registerRegistrationCli(OPTIONS);
@@ -8,6 +8,6 @@ export declare function buildCodexThreadPolicyFingerprint(input: {
8
8
  fullAuto?: boolean;
9
9
  bypassApprovalsAndSandbox?: boolean;
10
10
  }): string;
11
- export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): string | null;
12
- export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): void;
13
- export declare function clearStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
11
+ export declare function loadStoredThreadId(runtimeId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): string | null;
12
+ export declare function saveStoredThreadId(runtimeId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): void;
13
+ export declare function clearStoredThreadId(runtimeId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
@@ -1,20 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
1
  import { createHash } from 'node:crypto';
3
- import { join } from 'node:path';
4
- import { CANON_DIR, clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
5
- const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
6
- function loadStore() {
7
- try {
8
- return JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
9
- }
10
- catch {
11
- return { agents: {} };
12
- }
13
- }
14
- function saveStore(store) {
15
- mkdirSync(CANON_DIR, { recursive: true });
16
- writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
17
- }
2
+ import { clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
18
3
  export function buildCodexThreadPolicyFingerprint(input) {
19
4
  return createHash('sha256').update(JSON.stringify({
20
5
  version: 2,
@@ -27,91 +12,53 @@ export function buildCodexThreadPolicyFingerprint(input) {
27
12
  bypassApprovalsAndSandbox: input.bypassApprovalsAndSandbox === true,
28
13
  })).digest('hex').slice(0, 24);
29
14
  }
30
- export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode, policyFingerprint) {
31
- if (runtimeId) {
32
- const state = loadRuntimeSessionState(runtimeId, {
33
- conversationId,
34
- baseCwd,
35
- executionMode,
36
- });
37
- if (state?.threadId) {
38
- if (policyFingerprint && state.codexPolicyFingerprint !== policyFingerprint) {
39
- clearRuntimeSessionState(runtimeId, {
40
- conversationId,
41
- baseCwd,
42
- executionMode,
43
- });
44
- return null;
45
- }
46
- return state.threadId;
47
- }
48
- return null;
49
- }
50
- const store = loadStore();
51
- const record = store.agents[agentId]?.[conversationId];
52
- if (!record || record.cwd !== baseCwd)
53
- return null;
54
- if (policyFingerprint && record.policyFingerprint !== policyFingerprint) {
55
- delete store.agents[agentId][conversationId];
56
- if (Object.keys(store.agents[agentId]).length === 0) {
57
- delete store.agents[agentId];
15
+ export function loadStoredThreadId(runtimeId, conversationId, baseCwd, executionMode, policyFingerprint) {
16
+ const state = loadRuntimeSessionState(runtimeId, {
17
+ conversationId,
18
+ baseCwd,
19
+ executionMode,
20
+ });
21
+ if (state?.threadId) {
22
+ if (policyFingerprint && state.codexPolicyFingerprint !== policyFingerprint) {
23
+ clearRuntimeSessionState(runtimeId, {
24
+ conversationId,
25
+ baseCwd,
26
+ executionMode,
27
+ });
28
+ return null;
58
29
  }
59
- saveStore(store);
60
- return null;
30
+ return state.threadId;
61
31
  }
62
- return record.threadId;
32
+ return null;
63
33
  }
64
- export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode, policyFingerprint) {
65
- if (runtimeId) {
66
- saveRuntimeSessionState(runtimeId, {
67
- conversationId,
68
- baseCwd,
69
- executionMode,
70
- threadId,
71
- ...(policyFingerprint ? { codexPolicyFingerprint: policyFingerprint } : {}),
72
- });
73
- return;
74
- }
75
- const store = loadStore();
76
- store.agents[agentId] ??= {};
77
- store.agents[agentId][conversationId] = {
34
+ export function saveStoredThreadId(runtimeId, conversationId, baseCwd, threadId, executionMode, policyFingerprint) {
35
+ saveRuntimeSessionState(runtimeId, {
36
+ conversationId,
37
+ baseCwd,
38
+ executionMode,
78
39
  threadId,
79
- cwd: baseCwd,
80
- ...(policyFingerprint ? { policyFingerprint } : {}),
81
- updatedAt: new Date().toISOString(),
82
- };
83
- saveStore(store);
40
+ ...(policyFingerprint ? { codexPolicyFingerprint: policyFingerprint } : {}),
41
+ });
84
42
  }
85
- export function clearStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
86
- if (runtimeId) {
87
- const existing = baseCwd
88
- ? loadRuntimeSessionState(runtimeId, {
89
- conversationId,
90
- baseCwd,
91
- executionMode,
92
- })
93
- : null;
94
- clearRuntimeSessionState(runtimeId, {
43
+ export function clearStoredThreadId(runtimeId, conversationId, baseCwd, executionMode) {
44
+ const existing = baseCwd
45
+ ? loadRuntimeSessionState(runtimeId, {
95
46
  conversationId,
96
47
  baseCwd,
97
48
  executionMode,
49
+ })
50
+ : null;
51
+ clearRuntimeSessionState(runtimeId, {
52
+ conversationId,
53
+ baseCwd,
54
+ executionMode,
55
+ });
56
+ if (existing?.lastInboundMessageId && baseCwd) {
57
+ saveRuntimeSessionState(runtimeId, {
58
+ conversationId,
59
+ baseCwd,
60
+ ...(executionMode ? { executionMode } : {}),
61
+ lastInboundMessageId: existing.lastInboundMessageId,
98
62
  });
99
- if (existing?.lastInboundMessageId && baseCwd) {
100
- saveRuntimeSessionState(runtimeId, {
101
- conversationId,
102
- baseCwd,
103
- ...(executionMode ? { executionMode } : {}),
104
- lastInboundMessageId: existing.lastInboundMessageId,
105
- });
106
- }
107
- return;
108
- }
109
- const store = loadStore();
110
- if (!store.agents[agentId]?.[conversationId])
111
- return;
112
- delete store.agents[agentId][conversationId];
113
- if (Object.keys(store.agents[agentId]).length === 0) {
114
- delete store.agents[agentId];
115
63
  }
116
- saveStore(store);
117
64
  }
package/dist/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
- import { runCli } from './cli-entry.js';
3
+ import { runCli } from '@canonmsg/core';
4
4
  const HELP = `canon-codex-setup — print Canon Codex setup guidance
5
5
 
6
6
  USAGE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.23.4",
3
+ "version": "0.23.6",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,9 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^7.1.2",
33
- "@canonmsg/coding-agent-host": "^0.3.0",
34
- "@canonmsg/core": "^8.1.0"
32
+ "@canonmsg/agent-sdk": "^8.0.0",
33
+ "@canonmsg/coding-agent-host": "^0.4.0",
34
+ "@canonmsg/core": "^9.0.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"
@@ -1,2 +0,0 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
2
- export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core';
package/dist/cli-entry.js DELETED
@@ -1 +0,0 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
@@ -1,22 +0,0 @@
1
- import { type CanonGroupContext, type CanonGroupContextMode, type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
2
- export interface InboundParticipantContext {
3
- conversationType: CanonConversation['type'] | 'unknown';
4
- memberCount: number | null;
5
- senderType: 'human' | 'ai_agent';
6
- senderName: string;
7
- isOwner: boolean;
8
- mentionedAgent: boolean;
9
- groupContext?: CanonGroupContext;
10
- groupContextMode?: CanonGroupContextMode;
11
- recentSenderTypes: Array<'human' | 'ai_agent'>;
12
- recentHumanCount: number;
13
- recentAgentCount: number;
14
- consecutiveAgentTurns: number;
15
- currentAgentStreakStartedByHuman: boolean;
16
- }
17
- export interface AutoReplyDecision {
18
- allow: boolean;
19
- reason: string;
20
- }
21
- export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
22
- export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
@@ -1,46 +0,0 @@
1
- import { buildCompactGroupContextLines, evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
2
- function formatRecentSenders(senderTypes) {
3
- if (senderTypes.length === 0)
4
- return 'none';
5
- return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
6
- }
7
- export function buildInboundContextLines(context) {
8
- const conversationTypeLabel = context.conversationType === 'unknown'
9
- ? 'unknown'
10
- : `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
11
- const senderRole = context.isOwner
12
- ? 'The latest sender is the verified human owner of this Canon agent.'
13
- : context.senderType === 'ai_agent'
14
- ? 'The latest sender is another AI agent in Canon.'
15
- : 'The latest sender is a human Canon participant.';
16
- return [
17
- senderRole,
18
- `Latest sender name: ${context.senderName}`,
19
- `Latest sender type: ${context.senderType}`,
20
- `Conversation type: ${conversationTypeLabel}`,
21
- ...(context.groupContext && context.groupContextMode
22
- ? buildCompactGroupContextLines(context.groupContext, context.groupContextMode)
23
- : []),
24
- `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
25
- `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
26
- `Recent human messages: ${context.recentHumanCount}`,
27
- `Recent agent messages: ${context.recentAgentCount}`,
28
- `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
29
- `Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
30
- ];
31
- }
32
- export function decideAutoReply(context, behavior) {
33
- const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
34
- conversationType: context.conversationType,
35
- senderType: context.senderType,
36
- isOwner: context.isOwner,
37
- mentionedAgent: context.mentionedAgent,
38
- recentHumanCount: context.recentHumanCount,
39
- consecutiveAgentTurns: context.consecutiveAgentTurns,
40
- currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
41
- });
42
- return {
43
- allow: decision.allow,
44
- reason: decision.reason,
45
- };
46
- }
@@ -1,47 +0,0 @@
1
- /**
2
- * Startup recovery for inbound messages missed while the host was offline.
3
- *
4
- * The host persists a `lastInboundMessageId` cursor per conversation. On
5
- * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
- * its `before` message-id parameter) until the cursor is found or a hard
7
- * per-conversation bound is hit, then replay everything after the cursor.
8
- *
9
- * This module is intentionally identical in packages/claude-code-plugin and
10
- * packages/codex-plugin — keep both copies in sync (future consolidation
11
- * candidate).
12
- */
13
- export declare const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
- export declare const STARTUP_RECOVERY_MAX_MESSAGES = 500;
15
- export interface StartupRecoveryMessage {
16
- id: string;
17
- senderId: string;
18
- createdAt?: string;
19
- metadata?: Record<string, unknown>;
20
- }
21
- export interface StartupRecoveryPage {
22
- messages: StartupRecoveryMessage[];
23
- }
24
- export type StartupRecoveryMode =
25
- /** Cursor found — `messages` is everything strictly after it. */
26
- 'after-cursor'
27
- /** Cursor present but not found within the bound — `messages` is the bounded recent window. */
28
- | 'truncated-window'
29
- /**
30
- * No usable cursor (fresh runtime file, or the cursor message no longer
31
- * exists in history) — only the newest inbound message is recovered, since
32
- * a full-history replay could fire mass duplicate turns.
33
- */
34
- | 'latest-only';
35
- export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
36
- mode: StartupRecoveryMode;
37
- /** Missed inbound messages (own messages excluded), oldest first. */
38
- messages: TPage['messages'];
39
- /** First page fetched — reusable as hydration context for recovered turns. */
40
- newestPage: TPage;
41
- }
42
- export declare function collectMissedInboundMessages<TPage extends StartupRecoveryPage>(input: {
43
- fetchPage: (before?: string) => Promise<TPage>;
44
- cursor: string | null | undefined;
45
- agentId: string;
46
- maxMessages?: number;
47
- }): Promise<StartupRecoveryResult<TPage>>;
@@ -1,72 +0,0 @@
1
- /**
2
- * Startup recovery for inbound messages missed while the host was offline.
3
- *
4
- * The host persists a `lastInboundMessageId` cursor per conversation. On
5
- * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
- * its `before` message-id parameter) until the cursor is found or a hard
7
- * per-conversation bound is hit, then replay everything after the cursor.
8
- *
9
- * This module is intentionally identical in packages/claude-code-plugin and
10
- * packages/codex-plugin — keep both copies in sync (future consolidation
11
- * candidate).
12
- */
13
- export const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
- export const STARTUP_RECOVERY_MAX_MESSAGES = 500;
15
- export async function collectMissedInboundMessages(input) {
16
- const maxMessages = input.maxMessages ?? STARTUP_RECOVERY_MAX_MESSAGES;
17
- const newestPage = await input.fetchPage();
18
- const collected = [...newestPage.messages];
19
- const seenIds = new Set(collected.map((message) => message.id));
20
- const hasCursor = (messages) => input.cursor != null && messages.some((message) => message.id === input.cursor);
21
- let cursorFound = hasCursor(collected);
22
- if (input.cursor != null) {
23
- while (!cursorFound && collected.length < maxMessages) {
24
- // Pages are newest-first, so the last collected message is the oldest.
25
- const before = collected[collected.length - 1]?.id;
26
- if (!before)
27
- break;
28
- const page = await input.fetchPage(before);
29
- const fresh = page.messages.filter((message) => !seenIds.has(message.id));
30
- // No pagination progress (history exhausted, or the server ignored the
31
- // `before` cursor because that message was hard-deleted) — stop here.
32
- if (fresh.length === 0)
33
- break;
34
- for (const message of fresh)
35
- seenIds.add(message.id);
36
- collected.push(...fresh);
37
- cursorFound = hasCursor(fresh);
38
- }
39
- }
40
- // The REST API already gives us a deterministic newest-first sequence,
41
- // including messages with equal or missing timestamps. Reverse that wire
42
- // order rather than re-sorting on a lossy display timestamp.
43
- const ascending = [...collected].reverse();
44
- const completedSourceMessageIds = new Set(collected.flatMap((message) => {
45
- if (message.senderId !== input.agentId)
46
- return [];
47
- const metadata = message.metadata;
48
- if (metadata?.turnSemantics !== 'turn_complete')
49
- return [];
50
- return typeof metadata.sourceMessageId === 'string'
51
- ? [metadata.sourceMessageId]
52
- : [];
53
- }));
54
- const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId && !completedSourceMessageIds.has(message.id));
55
- let mode;
56
- let missed;
57
- if (cursorFound) {
58
- const cursorIndex = ascending.findIndex((message) => message.id === input.cursor);
59
- mode = 'after-cursor';
60
- missed = inboundOnly(ascending.slice(cursorIndex + 1));
61
- }
62
- else if (input.cursor != null && collected.length >= maxMessages) {
63
- mode = 'truncated-window';
64
- missed = inboundOnly(ascending.slice(-maxMessages));
65
- }
66
- else {
67
- mode = 'latest-only';
68
- missed = inboundOnly(ascending).slice(-1);
69
- }
70
- // Safe: `missed` only holds elements of pages returned by `fetchPage`.
71
- return { mode, messages: missed, newestPage };
72
- }