@canonmsg/codex-plugin 0.29.4 → 0.31.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.
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { buildConfiguredWorkspaceOptionsWithRoots, prepareConversationEnvironment, releaseConversationEnvironment, NativeWorkSessionCreateError, } from '@canonmsg/core';
5
+ import { CodexAttachedSessionAdapter, CodexAttachedSessionError, CodexSharedConnection, CodexSharedRpcError, listLoadedCodexSessions, } from './attached-session-adapter.js';
6
+ import { CODEX_PERMISSION_OPTIONS, mapCanonPermissionToCodex } from './permission-mode.js';
7
+ /** One shared native server; all selections are resolved locally from the advertised catalog. */
8
+ export class CodexWorkSessionProvider {
9
+ options;
10
+ connection;
11
+ projects;
12
+ permissionModes;
13
+ executionModes;
14
+ observers = new Set();
15
+ environments = new Set();
16
+ createdSettings = new Map();
17
+ creationAttempts = new Map();
18
+ creationFingerprints = new Map();
19
+ closed = false;
20
+ constructor(options) {
21
+ this.options = options;
22
+ this.connection = new CodexSharedConnection(options);
23
+ this.projects = buildConfiguredWorkspaceOptionsWithRoots({
24
+ primaryCwd: options.cwd, configuredWorkspaces: options.workspaces ?? [], workspaceRoots: options.workspaceRoots ?? [],
25
+ }).workspaceOptions;
26
+ this.permissionModes = [...new Set(options.permissionModes ?? ['readonly', 'workspace'])];
27
+ this.executionModes = [...new Set(options.executionModes ?? ['locked', 'worktree'])];
28
+ if (!this.permissionModes.length || this.permissionModes.some((mode) => !['readonly', 'workspace'].includes(mode))
29
+ || !this.executionModes.length || this.executionModes.some((mode) => !['locked', 'worktree'].includes(mode))) {
30
+ throw new Error('Configure at least one supported permission and execution mode.');
31
+ }
32
+ if (!this.permissionModes.includes(options.defaultPermissionMode ?? 'workspace')
33
+ || !this.executionModes.includes(options.defaultExecutionMode ?? 'worktree')) {
34
+ throw new Error('Default work-session policy must be allowed by this provider.');
35
+ }
36
+ }
37
+ async catalog() {
38
+ this.assertOpen();
39
+ await this.connection.connect();
40
+ const models = await this.models();
41
+ const loaded = await listLoadedCodexSessions(this.options, this.connection);
42
+ const configuredModel = models.find((model) => model.id === this.options.defaultModel || model.model === this.options.defaultModel);
43
+ if (this.options.defaultModel && !configuredModel)
44
+ throw new Error('The configured default model is not available on the shared server.');
45
+ const defaultModel = configuredModel
46
+ ?? models.find((model) => model.isDefault) ?? models[0];
47
+ const defaultEffort = this.options.defaultReasoningEffort ?? defaultModel?.defaultEffort;
48
+ if (defaultEffort && !defaultModel?.efforts.some((effort) => effort.id === defaultEffort))
49
+ throw new Error('The configured reasoning effort is not supported by the default model.');
50
+ const defaults = {
51
+ ...(this.projects[0] ? { projectId: this.projects[0].id } : {}),
52
+ ...(defaultModel ? { modelId: defaultModel.id } : {}),
53
+ ...(defaultEffort ? { reasoningEffort: defaultEffort } : {}),
54
+ permissionMode: this.options.defaultPermissionMode ?? 'workspace', executionMode: this.options.defaultExecutionMode ?? 'worktree',
55
+ };
56
+ const catalog = {
57
+ provider: 'codex', canCreate: this.projects.length > 0 && models.length > 0, canAttach: true,
58
+ projects: this.projects.map(({ id, label }) => ({ id, label: catalogLabel(label, 'Project') })),
59
+ models: models.map((model) => ({ id: model.id, label: model.label, reasoningEfforts: model.efforts,
60
+ ...(model.defaultEffort ? { defaultReasoningEffort: model.defaultEffort } : {}),
61
+ })),
62
+ permissionModes: this.permissionModes.map((id) => ({ id, label: CODEX_PERMISSION_OPTIONS.find((entry) => entry.value === id).label })),
63
+ executionModes: this.executionModes.map((id) => ({ id, label: id === 'worktree' ? 'Git worktree' : 'Project folder' })),
64
+ defaults,
65
+ sessions: loaded.map((session) => {
66
+ const project = this.projects.find((project) => session.cwd && samePath(project.cwd, session.cwd));
67
+ const model = models.find((model) => model.model === session.model || model.id === session.model);
68
+ const saved = this.createdSettings.get(session.threadId);
69
+ return {
70
+ id: session.threadId, title: catalogLabel(session.name, 'Untitled Codex session'), status: session.status ?? 'idle',
71
+ settings: { ...(saved ?? {}), ...(project ? { projectId: project.id } : {}),
72
+ ...(session.model ? { modelId: model?.id ?? modelChoiceId(session.model) } : {}),
73
+ ...(session.reasoningEffort ? { reasoningEffort: session.reasoningEffort } : {}),
74
+ },
75
+ ...(session.cwd ? { projectLabel: catalogLabel(project?.label ?? basename(session.cwd), 'Project') } : {}),
76
+ ...(session.model ? { modelLabel: model?.label ?? catalogLabel(session.model, 'Native model') } : {}),
77
+ };
78
+ }),
79
+ };
80
+ // A revision identifies the entire published catalog, including native
81
+ // activity and settings. The broker rejects different content at one revision.
82
+ return { revision: createHash('sha256').update(JSON.stringify(catalog)).digest('hex').slice(0, 24), ...catalog };
83
+ }
84
+ create(selection, context) {
85
+ const fingerprint = JSON.stringify([selection, context]);
86
+ const prior = this.creationAttempts.get(context.requestId);
87
+ if (prior)
88
+ return this.creationFingerprints.get(context.requestId) === fingerprint ? prior
89
+ : Promise.reject(new NativeWorkSessionCreateError('A work-session request ID was reused with different settings.', 'not_created'));
90
+ const attempt = this.createOnce(selection, context);
91
+ this.creationAttempts.set(context.requestId, attempt);
92
+ this.creationFingerprints.set(context.requestId, fingerprint);
93
+ return attempt;
94
+ }
95
+ async createOnce(selection, context) {
96
+ let nativeWritten = false;
97
+ try {
98
+ this.assertOpen();
99
+ if (!context.requestId || !context.conversationId || !context.agentId)
100
+ throw new Error('A stable work-session request context is required.');
101
+ if (selection.mode !== 'create' || Object.keys(selection).some((key) => !['mode', 'projectId', 'modelId', 'reasoningEffort', 'permissionMode', 'executionMode'].includes(key))) {
102
+ throw new Error('Unsupported native session creation settings.');
103
+ }
104
+ const project = this.projects.find((project) => project.id === selection.projectId);
105
+ if (!project)
106
+ throw new Error('The selected project is not configured on this host.');
107
+ const permissionMode = selection.permissionMode ?? this.options.defaultPermissionMode ?? 'workspace';
108
+ const executionMode = selection.executionMode ?? this.options.defaultExecutionMode ?? 'worktree';
109
+ if (!this.permissionModes.includes(permissionMode) || !this.executionModes.includes(executionMode)) {
110
+ throw new Error('The selected native permission or execution mode is not allowed.');
111
+ }
112
+ await this.connection.connect();
113
+ const models = await this.models();
114
+ const modelId = selection.modelId ?? this.options.defaultModel;
115
+ const model = modelId ? models.find((entry) => entry.id === modelId || entry.model === modelId)
116
+ : models.find((entry) => entry.isDefault) ?? models[0];
117
+ if (!model)
118
+ throw new Error('The selected model is not available on this shared server.');
119
+ const reasoningEffort = selection.reasoningEffort ?? this.options.defaultReasoningEffort ?? model.defaultEffort;
120
+ if (reasoningEffort && !model.efforts.some((effort) => effort.id === reasoningEffort))
121
+ throw new Error('The selected reasoning effort is not supported by this model.');
122
+ const environment = (this.options.prepareEnvironment ?? prepareConversationEnvironment)({
123
+ agentId: context.agentId, conversationId: `work-session:${context.requestId}`,
124
+ workspaceCwd: project.cwd, allowWorktrees: executionMode === 'worktree',
125
+ });
126
+ this.environments.add(environment);
127
+ if (environment.mode !== executionMode)
128
+ throw new Error('The requested Git worktree could not be created. Select Project folder explicitly to use the base project.');
129
+ this.assertOpen();
130
+ const policy = mapCanonPermissionToCodex(permissionMode);
131
+ nativeWritten = true;
132
+ const result = object(await this.connection.createThread({
133
+ cwd: environment.cwd, model: model.model, sandbox: policy.sandbox, approvalPolicy: 'on-request',
134
+ ephemeral: false, allowProviderModelFallback: false,
135
+ ...(reasoningEffort ? { config: { model_reasoning_effort: reasoningEffort } } : {}),
136
+ }));
137
+ const thread = object(result?.thread);
138
+ const nativeSessionId = string(thread?.id);
139
+ const actualModel = string(result?.model) ?? string(thread?.model);
140
+ const actualEffort = string(result?.reasoningEffort) ?? string(thread?.reasoningEffort);
141
+ const actualSandbox = string(object(result?.sandbox)?.type) ?? string(result?.sandbox);
142
+ const expectedSandbox = permissionMode === 'readonly' ? ['readOnly', 'read-only'] : ['workspaceWrite', 'workspace-write'];
143
+ if (!nativeSessionId || !actualModel || !expectedSandbox.includes(actualSandbox ?? '') || result?.approvalPolicy !== 'on-request') {
144
+ throw new Error('The native server returned an incomplete or unexpected creation policy; review the created session before sharing it.');
145
+ }
146
+ const actualSettings = {
147
+ projectId: project.id, modelId: models.find((entry) => entry.model === actualModel)?.id ?? modelChoiceId(actualModel),
148
+ ...(actualEffort ? { reasoningEffort: actualEffort } : {}), permissionMode, executionMode: environment.mode,
149
+ };
150
+ this.createdSettings.set(nativeSessionId, actualSettings);
151
+ return { nativeSessionId, settings: { ...actualSettings } };
152
+ }
153
+ catch (error) {
154
+ if (error instanceof NativeWorkSessionCreateError)
155
+ throw error;
156
+ const rejected = error instanceof CodexSharedRpcError && [-32600, -32601, -32602].includes(error.code ?? 0);
157
+ const definitelyUnwritten = error instanceof CodexAttachedSessionError && !error.submissionUncertain;
158
+ throw new NativeWorkSessionCreateError(error instanceof Error ? error.message : String(error), !nativeWritten || rejected || definitelyUnwritten ? 'not_created' : 'uncertain', { cause: error });
159
+ }
160
+ }
161
+ async open(nativeSessionId, interactionBridge) {
162
+ this.assertOpen();
163
+ const adapter = new CodexAttachedSessionAdapter({ ...this.options, threadId: nativeSessionId, sharedConnection: this.connection, interactionBridge });
164
+ this.observers.add(adapter);
165
+ return adapter;
166
+ }
167
+ async close() {
168
+ if (this.closed)
169
+ return;
170
+ this.closed = true;
171
+ await Promise.allSettled([...this.observers].map((observer) => observer.close()));
172
+ this.observers.clear();
173
+ await this.connection.close();
174
+ // Only advisory usage records are released. Never remove a native worktree.
175
+ for (const environment of this.environments)
176
+ releaseConversationEnvironment(environment);
177
+ }
178
+ assertOpen() { if (this.closed)
179
+ throw new Error('The native work-session provider is closed.'); }
180
+ async models() {
181
+ const models = [];
182
+ const modelIds = new Set();
183
+ const seen = new Set();
184
+ let cursor;
185
+ do {
186
+ const page = object(await this.connection.request('model/list', { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) }));
187
+ if (!page || !Array.isArray(page.data))
188
+ throw new Error('The shared server returned an invalid model catalog.');
189
+ for (const raw of page.data) {
190
+ const entry = object(raw);
191
+ const id = string(entry?.id);
192
+ const model = string(entry?.model) ?? id;
193
+ if (!entry || !id || !model || entry.hidden === true)
194
+ continue;
195
+ const choiceId = modelChoiceId(id);
196
+ if (modelIds.has(choiceId))
197
+ continue;
198
+ modelIds.add(choiceId);
199
+ const efforts = Array.isArray(entry.supportedReasoningEfforts) ? entry.supportedReasoningEfforts.flatMap((raw) => {
200
+ const value = string(object(raw)?.reasoningEffort);
201
+ return value ? [{ id: value, label: value[0].toUpperCase() + value.slice(1) }] : [];
202
+ }) : [];
203
+ models.push({ id: choiceId, model, label: catalogLabel(string(entry.displayName) ?? model, 'Native model'), efforts, isDefault: entry.isDefault === true,
204
+ ...(string(entry.defaultReasoningEffort) ? { defaultEffort: entry.defaultReasoningEffort } : {}),
205
+ });
206
+ }
207
+ if (page.nextCursor != null && !string(page.nextCursor))
208
+ throw new Error('The shared server returned an invalid model cursor.');
209
+ cursor = string(page.nextCursor);
210
+ if (cursor && seen.has(cursor))
211
+ throw new Error('The shared server repeated a model cursor.');
212
+ if (cursor)
213
+ seen.add(cursor);
214
+ } while (cursor);
215
+ return models;
216
+ }
217
+ }
218
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null; }
219
+ function string(value) { return typeof value === 'string' && value.length ? value : undefined; }
220
+ function samePath(left, right) {
221
+ try {
222
+ return realpathSync(left) === realpathSync(right);
223
+ }
224
+ catch {
225
+ return left === right;
226
+ }
227
+ }
228
+ function modelChoiceId(value) {
229
+ return /^[A-Za-z0-9_.:-]{1,160}$/.test(value) ? value
230
+ : `model-${createHash('sha256').update(value).digest('hex').slice(0, 32)}`;
231
+ }
232
+ function catalogLabel(value, fallback) {
233
+ return value?.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 160) || fallback;
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.29.4",
3
+ "version": "0.31.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "bin": {
15
15
  "canon-codex": "dist/host.js",
16
+ "canon-codex-attach": "dist/attach.js",
16
17
  "canon-codex-register": "dist/register.js",
17
18
  "canon-codex-setup": "dist/setup.js"
18
19
  },
@@ -25,15 +26,17 @@
25
26
  "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
26
27
  "dev": "npm run prepare:workspace-deps && tsc --watch",
27
28
  "smoke": "node scripts/smoke-test.mjs",
29
+ "smoke:attach": "node scripts/smoke-attach.mjs",
28
30
  "test": "vitest run",
29
31
  "prepack": "npm run build"
30
32
  },
31
33
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^10.2.1",
33
- "@canonmsg/agent-tools": "^0.9.0",
34
- "@canonmsg/coding-agent-host": "^0.7.0",
35
- "@canonmsg/core": "^12.3.1",
36
- "@canonmsg/rich-cards": "^0.10.4"
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",
39
+ "ws": "^8.21.3"
37
40
  },
38
41
  "engines": {
39
42
  "node": ">=18.0.0"
@@ -45,12 +48,7 @@
45
48
  "messaging",
46
49
  "host-mode"
47
50
  ],
48
- "repository": {
49
- "type": "git",
50
- "url": "https://github.com/HeyBobChan/canon",
51
- "directory": "packages/codex-plugin"
52
- },
53
- "homepage": "https://github.com/HeyBobChan/canon/tree/main/packages/codex-plugin",
51
+ "homepage": "https://canonmail.com/agents/integrations#codex",
54
52
  "publishConfig": {
55
53
  "access": "public"
56
54
  },
@@ -59,5 +57,8 @@
59
57
  "typescript": "~5.7.0",
60
58
  "vitest": "^4.1.8"
61
59
  },
62
- "license": "MIT"
60
+ "license": "MIT",
61
+ "bugs": {
62
+ "url": "https://canonmail.com/support"
63
+ }
63
64
  }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Real local Codex protocol, fake localhost model and Canon publisher. No model
4
+ // API or Canon account calls. Build Core, SDK and this package before running.
5
+ import assert from 'node:assert/strict';
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { createServer as httpServer } from 'node:http';
9
+ import { createServer as netServer } from 'node:net';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import WebSocket from 'ws';
13
+ import { createAttachedNativeSession, createFileAttachedSessionStore } from '@canonmsg/core';
14
+ import { createCanonAttachedSessionPublisher } from '@canonmsg/agent-sdk';
15
+ import { CodexAttachedSessionAdapter } from '../dist/attached-session-adapter.js';
16
+
17
+ const binary = process.argv[2] ?? 'codex';
18
+ const directory = await mkdtemp(join(tmpdir(), 'canon-attach-smoke-'));
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ async function until(predicate, description) {
21
+ for (let attempt = 0; attempt < 300; attempt += 1) {
22
+ if (await predicate()) return;
23
+ await sleep(50);
24
+ }
25
+ throw new Error(`Timed out: ${description}`);
26
+ }
27
+ async function listen(server) {
28
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
29
+ return server.address().port;
30
+ }
31
+
32
+ let providerCalls = 0;
33
+ let requestApprovalOnNextModelCall = false;
34
+ const provider = httpServer((request, response) => {
35
+ request.resume();
36
+ providerCalls += 1;
37
+ const item = requestApprovalOnNextModelCall ? {
38
+ id: 'approval_function', type: 'function_call', call_id: 'approval_call', name: 'exec_command',
39
+ arguments: JSON.stringify({
40
+ cmd: 'printf canon-approval-smoke', sandbox_permissions: 'require_escalated',
41
+ justification: 'Disposable approval routing check; the native client will decline.',
42
+ }),
43
+ } : {
44
+ id: `message_${providerCalls}`, type: 'message', role: 'assistant', status: 'completed',
45
+ content: [{ type: 'output_text', text: `Mock answer ${providerCalls}.`, annotations: [] }],
46
+ };
47
+ requestApprovalOnNextModelCall = false;
48
+ const result = {
49
+ id: `response_${providerCalls}`, object: 'response', model: 'probe', status: 'completed',
50
+ output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
51
+ };
52
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
53
+ for (const event of [
54
+ { type: 'response.created', response: { ...result, status: 'in_progress', output: [] } },
55
+ { type: 'response.output_item.added', output_index: 0, item: item.type === 'message' ? { ...item, status: 'in_progress', content: [] } : item },
56
+ ...(item.type === 'message' ? [{ type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: item.id, delta: item.content[0].text }] : []),
57
+ { type: 'response.output_item.done', output_index: 0, item },
58
+ { type: 'response.completed', response: result },
59
+ ]) response.write(`data: ${JSON.stringify(event)}\n\n`);
60
+ response.end();
61
+ });
62
+ const modelPort = await listen(provider);
63
+ const reservation = netServer();
64
+ const port = await listen(reservation);
65
+ await new Promise((resolve) => reservation.close(resolve));
66
+ const endpoint = `ws://127.0.0.1:${port}`;
67
+ const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _originatorOverride, ...serverEnv } = process.env;
68
+ const server = spawn(binary, ['app-server', '--disable', 'plugins', '--disable', 'apps', '--listen', endpoint], {
69
+ cwd: directory, env: serverEnv, stdio: ['ignore', 'pipe', 'pipe'],
70
+ });
71
+ let serverError = '';
72
+ server.stderr.on('data', (data) => { serverError = `${serverError}${data}`.slice(-1_000); });
73
+ server.stdout.resume();
74
+ let socket;
75
+ let native;
76
+ let controller;
77
+ let threadId;
78
+ const events = [];
79
+ const nativeRequests = [];
80
+ const observerRequests = [];
81
+ const observerResponses = [];
82
+ const published = new Map();
83
+ const errors = [];
84
+
85
+ try {
86
+ await until(async () => {
87
+ if (server.exitCode !== null) throw new Error(`Codex exited: ${serverError}`);
88
+ return fetch(`http://127.0.0.1:${port}/readyz`).then((result) => result.ok).catch(() => false);
89
+ }, 'shared app-server readiness');
90
+ socket = new WebSocket(endpoint);
91
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
92
+ let sequence = 0;
93
+ const pending = new Map();
94
+ socket.on('message', (data) => {
95
+ const message = JSON.parse(data.toString());
96
+ if (message.method) {
97
+ if ('id' in message) nativeRequests.push(message);
98
+ else events.push(message);
99
+ return;
100
+ }
101
+ const waiter = pending.get(message.id);
102
+ if (!waiter) { events.push(message); return; }
103
+ pending.delete(message.id);
104
+ clearTimeout(waiter.timer);
105
+ if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
106
+ else waiter.resolve(message.result);
107
+ });
108
+ native = (method, params = {}) => new Promise((resolve, reject) => {
109
+ const id = ++sequence;
110
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 10_000);
111
+ pending.set(id, { resolve, reject, timer });
112
+ socket.send(JSON.stringify({ id, method, params }));
113
+ });
114
+ await native('initialize', { clientInfo: { name: 'canon_attachment_smoke', version: '0.0.0' }, capabilities: { experimentalApi: true } });
115
+ socket.send(JSON.stringify({ method: 'initialized' }));
116
+ const started = await native('thread/start', {
117
+ cwd: directory, ephemeral: false, model: 'probe', modelProvider: 'canon_smoke',
118
+ approvalPolicy: 'on-request', sandbox: 'read-only',
119
+ config: {
120
+ mcp_servers: {}, 'features.apps': false, 'features.plugins': false,
121
+ 'model_providers.canon_smoke': {
122
+ name: 'Canon local mock provider', base_url: `http://127.0.0.1:${modelPort}/v1`,
123
+ wire_api: 'responses', requires_openai_auth: false,
124
+ },
125
+ },
126
+ });
127
+ threadId = started.thread.id;
128
+ async function nativeTurn(text) {
129
+ const result = await native('turn/start', { threadId, input: [{ type: 'text', text }] });
130
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === result.turn.id), 'native turn completion');
131
+ return result.turn.id;
132
+ }
133
+ await nativeTurn('Private text from before attachment.');
134
+ const binding = { environmentId: 'smoke', agentId: 'smoke-agent', conversationId: 'smoke-room', provider: 'codex', nativeSessionId: threadId };
135
+ const publisher = createCanonAttachedSessionPublisher({
136
+ async sendProactiveMessage(conversationId, text, options) {
137
+ assert.equal(conversationId, binding.conversationId);
138
+ const payload = JSON.stringify({ text, options });
139
+ if (published.has(options.messageId)) assert.equal(published.get(options.messageId), payload);
140
+ published.set(options.messageId, payload);
141
+ return { messageId: options.messageId, conversationId };
142
+ },
143
+ }, binding);
144
+ const attach = async () => {
145
+ const value = createAttachedNativeSession({
146
+ binding, adapter: new CodexAttachedSessionAdapter({ endpoint, threadId, createSocket(url) {
147
+ const observer = new WebSocket(url);
148
+ observer.on('message', (data) => {
149
+ const message = JSON.parse(data.toString());
150
+ if (message.method && 'id' in message) observerRequests.push(message);
151
+ });
152
+ const send = observer.send.bind(observer);
153
+ observer.send = (data, ...args) => {
154
+ const message = JSON.parse(data.toString());
155
+ if ('id' in message && !message.method) observerResponses.push(message);
156
+ return send(data, ...args);
157
+ };
158
+ return observer;
159
+ } }),
160
+ store: createFileAttachedSessionStore(join(directory, 'attachment.json')), publisher,
161
+ onError: (error, operation) => errors.push(`${operation}: ${error.message}`),
162
+ });
163
+ await value.start();
164
+ return value;
165
+ };
166
+ controller = await attach();
167
+ assert.equal(published.size, 0, 'Private history must not be imported');
168
+ await nativeTurn('New input on the native surface.');
169
+ await controller.reconcile();
170
+ assert.equal(published.size, 2, 'Local user and assistant messages should both publish');
171
+ const accepted = await controller.submit({ messageId: 'canon-input-1', text: 'Input from the Canon surface.' });
172
+ assert.equal(accepted.status, 'accepted');
173
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === accepted.turnId), 'Canon-triggered native completion');
174
+ await controller.reconcile();
175
+ assert.equal(published.size, 3, 'The Canon input echo must be suppressed');
176
+ const sameInput = await controller.submit({ messageId: 'canon-input-1', text: 'Input from the Canon surface.' });
177
+ assert.equal(sameInput.replayed, true, 'Duplicate Canon delivery must not execute twice');
178
+ assert.equal(providerCalls, 3);
179
+ const observed = await native('thread/read', { threadId, includeTurns: true });
180
+ assert.ok(JSON.stringify(observed.thread.turns).includes('Input from the Canon surface.'), 'Native client must see Canon input in its own transcript');
181
+ await controller.stop();
182
+ await nativeTurn('Private work after a graceful detach.');
183
+ controller = await attach();
184
+ assert.equal(published.size, 3, 'Graceful detachment must exclude later private work');
185
+ await nativeTurn('New shared input after reattaching.');
186
+ await controller.reconcile();
187
+ assert.equal(published.size, 5);
188
+ requestApprovalOnNextModelCall = true;
189
+ const approvalTurn = await controller.submit({ messageId: 'canon-approval-input', text: 'Exercise native approval routing with the disposable mock command.' });
190
+ assert.equal(approvalTurn.status, 'accepted');
191
+ const approvalForTurn = (request) => request.method === 'item/commandExecution/requestApproval' && request.params.turnId === approvalTurn.turnId;
192
+ await until(() => nativeRequests.some(approvalForTurn) && observerRequests.some(approvalForTurn), 'approval delivered to both native and attached clients');
193
+ const nativeApproval = nativeRequests.find(approvalForTurn);
194
+ assert.equal(nativeApproval.id, observerRequests.find(approvalForTurn).id, 'Both clients should see the same pending approval');
195
+ assert.equal(observerResponses.length, 0, 'The Canon observer must not answer native approval requests');
196
+ assert.ok(!events.some((event) => event.method === 'turn/completed' && event.params.turn.id === approvalTurn.turnId), 'The turn must wait for the native approval decision');
197
+ // Decline from the native surface. The mock command is never executed.
198
+ socket.send(JSON.stringify({ id: nativeApproval.id, result: { decision: 'decline' } }));
199
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === approvalTurn.turnId), 'completion after native approval decline');
200
+ assert.ok(events.some((event) => event.method === 'serverRequest/resolved' && event.params.requestId === nativeApproval.id), 'Native decision should resolve the shared approval');
201
+ assert.ok(events.some((event) => event.method === 'item/completed' && event.params.item.id === nativeApproval.params.itemId && event.params.item.status === 'declined'), 'The requested command must be declined without execution');
202
+ await controller.reconcile();
203
+ assert.equal(published.size, 6, 'Only the final assistant text should mirror from the Canon approval turn');
204
+ assert.equal(observerResponses.length, 0);
205
+ assert.equal(errors.length, 0, errors.join('\n'));
206
+ await controller.stop();
207
+ const alive = await native('thread/read', { threadId });
208
+ assert.equal(alive.thread.id, threadId, 'Detachment must leave the shared runner available');
209
+ console.log(JSON.stringify({ passed: true, nativeAndCanonInput: true, privateHistoryExcluded: true, echoSuppressed: true, replayDeduplicated: true, nativeApprovalRouting: true, detachKeepsNativeAlive: true, publishedMessages: published.size, localMockModelCalls: providerCalls }));
210
+ } finally {
211
+ await controller?.stop().catch(() => {});
212
+ if (threadId && socket?.readyState === WebSocket.OPEN) {
213
+ await native('thread/delete', { threadId }).catch((error) => console.error(`Could not delete disposable thread ${threadId}: ${error.message}`));
214
+ }
215
+ socket?.terminate();
216
+ server.kill('SIGTERM');
217
+ await sleep(500);
218
+ if (server.exitCode === null) server.kill('SIGKILL');
219
+ provider.closeAllConnections();
220
+ await new Promise((resolve) => provider.close(resolve));
221
+ await rm(directory, { recursive: true, force: true });
222
+ }