@canonmsg/codex-plugin 0.29.4 → 0.30.1

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.js CHANGED
@@ -18,11 +18,16 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
18
18
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
19
19
  import { startCodexStreamInBackground } from './host-lifecycle.js';
20
20
  import { createCodexControlPoller } from './control-channel.js';
21
- import { runCli } from '@canonmsg/core';
21
+ import { runCli, acquireLock } from '@canonmsg/core';
22
+ import { runCodexWorkSessionHost } from './work-session-cli.js';
23
+ import { CODEX_WORK_SESSION_HELP, codexAccountLockKey, isSharedWorkSessionCommand } from './work-session-options.js';
22
24
  import { applyTextSegmentBlock, beginCommandBlock, buildCodexFinalTurnTrail, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
23
25
  import { FALLBACK_CODEX_EFFORT_OPTIONS, buildCodexEffortOptions, buildCodexModelOptions, readCodexConfiguredEffort, resolveCodexDefaultModel, resolveCodexEffortForModel, } from './model-catalog.js';
26
+ let managedAccountLock;
24
27
  const HELP = `canon-codex — run a local Codex agent host for Canon
25
28
 
29
+ ${CODEX_WORK_SESSION_HELP}
30
+
26
31
  USAGE
27
32
  canon-codex [flags]
28
33
 
@@ -601,6 +606,10 @@ export function shouldRouteCodexTurnArtifacts(input) {
601
606
  }
602
607
  export async function main() {
603
608
  setDefaultResultOrder('ipv4first');
609
+ if (isSharedWorkSessionCommand(process.argv.slice(2))) {
610
+ await runCodexWorkSessionHost(process.argv.slice(2));
611
+ return;
612
+ }
604
613
  const { values: args } = parseArgs({
605
614
  options: {
606
615
  cwd: { type: 'string' },
@@ -704,6 +713,9 @@ export async function main() {
704
713
  }
705
714
  console.error(`[canon-codex] Authenticated as ${agentId}`);
706
715
  }
716
+ // One local owner across managed, standalone attach and multi-room shared modes,
717
+ // including profile aliases and directly supplied credentials for this account.
718
+ managedAccountLock = acquireLock(codexAccountLockKey({ environmentId: resolvedAgent.environmentId, agentId }));
707
719
  let codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
708
720
  // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
709
721
  // create+poll (codex passes native/responder policy via payload/options).
@@ -2820,6 +2832,7 @@ export async function main() {
2820
2832
  closeSession(session.conversationId);
2821
2833
  }
2822
2834
  markLocalRuntimeStopped(runtimeId);
2835
+ managedAccountLock?.release();
2823
2836
  (lockHandle ?? getActiveProfileLock())?.release();
2824
2837
  process.exit(0);
2825
2838
  };
@@ -2832,6 +2845,7 @@ export async function main() {
2832
2845
  runCli(import.meta.url, main, (error) => {
2833
2846
  const message = error instanceof Error ? error.message : String(error);
2834
2847
  console.error(`[canon-codex] ${message}`);
2848
+ managedAccountLock?.release();
2835
2849
  getActiveProfileLock()?.release();
2836
2850
  process.exit(1);
2837
2851
  }, {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
+ export { main as attachMain } from './attach.js';
5
+ export { CodexAttachedSessionAdapter, listLoadedCodexSessions } from './attached-session-adapter.js';
6
+ export type { CodexAttachedSessionOptions } from './attached-session-adapter.js';
7
+ export { CodexWorkSessionProvider } from './work-session-provider.js';
8
+ export type { CodexWorkSessionProviderOptions } from './work-session-provider.js';
4
9
  export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
5
10
  export type { CanonCodexApprovalRequest, CodexAppServerApprovalMethod, CodexApprovalDecision, CodexNativeApprovalRequest, } from './app-server-approval.js';
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
+ export { main as attachMain } from './attach.js';
5
+ export { CodexAttachedSessionAdapter, listLoadedCodexSessions } from './attached-session-adapter.js';
6
+ export { CodexWorkSessionProvider } from './work-session-provider.js';
4
7
  export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
package/dist/setup.js CHANGED
@@ -37,6 +37,11 @@ export function main() {
37
37
  console.log(' A git repo is not required; any readable directory works.');
38
38
  console.log(' Use --workspace-root to let Canon offer discovered projects inside an approved root.');
39
39
  console.log('');
40
+ console.log(' To share an existing native CLI thread instead, see:');
41
+ console.log(' canon-codex-attach --help');
42
+ console.log(' This experimental mode requires an explicitly shared local app-server.');
43
+ console.log(' Private Codex Desktop servers cannot currently be attached.');
44
+ console.log('');
40
45
  console.log('Optional flags:');
41
46
  console.log(' --model gpt-5.4');
42
47
  console.log(' --sandbox workspace-write');
@@ -0,0 +1,2 @@
1
+ /** Opt-in shared native runtime; never starts or takes ownership of the native server. */
2
+ export declare function runCodexWorkSessionHost(argv: string[]): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { CANON_DIR, CanonClient, acquireLock, createFileAttachedSessionStore, resolveCanonAgent, resolveCanonProfile, verifyResolvedAgentEnvironment, } from '@canonmsg/core';
2
+ import { createCanonWorkSessionHost, createFileWorkSessionHostStore } from '@canonmsg/agent-sdk';
3
+ import { attachedSessionStatePath } from './attach-options.js';
4
+ import { CodexWorkSessionProvider } from './work-session-provider.js';
5
+ import { codexAccountLockKey, codexNativeSessionLockKey, parseCodexWorkSessionOptions, resolveWorkSessionInstallation, } from './work-session-options.js';
6
+ /** Opt-in shared native runtime; never starts or takes ownership of the native server. */
7
+ export async function runCodexWorkSessionHost(argv) {
8
+ const options = parseCodexWorkSessionOptions(argv);
9
+ const resolution = { logPrefix: '[canon-codex]', expectedClientType: 'codex', lock: true };
10
+ const profile = options.profile ? resolveCanonProfile(options.profile, resolution) : resolveCanonAgent(resolution);
11
+ let accountLock;
12
+ let provider;
13
+ let host;
14
+ let stopping = false;
15
+ let resolveShutdown = () => { };
16
+ const shutdown = new Promise((resolve) => { resolveShutdown = resolve; });
17
+ const stop = () => {
18
+ stopping = true;
19
+ void host?.stop().catch((error) => console.error('[canon-codex]', error));
20
+ resolveShutdown();
21
+ };
22
+ process.once('SIGINT', stop);
23
+ process.once('SIGTERM', stop);
24
+ process.once('SIGHUP', stop);
25
+ try {
26
+ await verifyResolvedAgentEnvironment(profile);
27
+ const agentId = profile.agentId ?? (await new CanonClient(profile.apiKey, profile.baseUrl).getAuthToken()).agentId;
28
+ accountLock = acquireLock(codexAccountLockKey({ environmentId: profile.environmentId, agentId }));
29
+ const installation = resolveWorkSessionInstallation(CANON_DIR, { environmentId: profile.environmentId, agentId });
30
+ provider = new CodexWorkSessionProvider({ ...options,
31
+ onError: (error) => console.error('[canon-codex] Native server:', error.message),
32
+ });
33
+ host = createCanonWorkSessionHost({ connection: { ...profile, agentId },
34
+ hostId: installation.hostId, displayName: profile.agentName ?? 'Codex work sessions', provider,
35
+ store: createFileWorkSessionHostStore(installation.journalPath),
36
+ createSessionStore: (binding) => createFileAttachedSessionStore(attachedSessionStatePath(CANON_DIR, binding)),
37
+ acquireNativeSession: (binding) => {
38
+ const lock = acquireLock(codexNativeSessionLockKey(binding.nativeSessionId));
39
+ return () => lock.release();
40
+ },
41
+ onStatus: (event) => {
42
+ if (event.status === 'available')
43
+ console.error('[canon-codex] Ready. In Canon choose Start or add work session. Keep this terminal and the shared Codex server running.');
44
+ else if (event.status === 'stopped') {
45
+ stopping = true;
46
+ resolveShutdown();
47
+ }
48
+ else
49
+ console.error('[canon-codex]', event.conversationId, event.session?.status);
50
+ },
51
+ onError: (error, operation) => console.error(`[canon-codex] ${operation}:`, error instanceof Error ? error.message : String(error)),
52
+ });
53
+ if (!stopping)
54
+ await host.start();
55
+ if (!stopping)
56
+ await shutdown;
57
+ }
58
+ finally {
59
+ process.removeListener('SIGINT', stop);
60
+ process.removeListener('SIGTERM', stop);
61
+ process.removeListener('SIGHUP', stop);
62
+ try {
63
+ await host?.stop();
64
+ }
65
+ finally {
66
+ try {
67
+ await provider?.close();
68
+ }
69
+ finally {
70
+ accountLock?.release();
71
+ profile.lockHandle?.release();
72
+ }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,33 @@
1
+ export declare const CODEX_WORK_SESSION_HELP = "SHARED WORK SESSIONS (experimental; Codex 0.154 or newer)\n canon-codex --shared-server ws://127.0.0.1:4500 --cwd /path/to/project\n\n --shared-server <url> Explicit local WebSocket app-server\n --profile <name> Canon profile (or use CANON_AGENT)\n --cwd <path> Default permitted project\n --workspace <path> Additional permitted project; repeatable\n --workspace-root <path> Discover projects under a permitted root\n --model <id> Default model from native model/list\n --reasoning-effort <id> Default effort supported by that model\n --default-permission-mode <mode> readonly or workspace (default workspace)\n --permission-mode <mode> Allowed permission choices; repeatable\n --default-execution-mode <mode> locked or worktree (default worktree)\n --execution-mode <mode> Allowed execution choices; repeatable\n\nIn Canon choose Start or add work session. New sessions use on-request approval;\nthe account owner can answer native approvals and questions in Canon. Existing\nloaded sessions preserve their native settings. Native clients can keep using\nthe same thread. Ctrl-C stops sharing without stopping the native server.\nDesktop's private server is unsupported. Managed-host flags do not apply here.";
2
+ export declare function isSharedWorkSessionCommand(argv: string[]): boolean;
3
+ export declare function parseCodexWorkSessionOptions(argv: string[]): {
4
+ endpoint: string;
5
+ profile: string | undefined;
6
+ cwd: string;
7
+ workspaces: string[] | undefined;
8
+ workspaceRoots: string[] | undefined;
9
+ defaultModel: string | undefined;
10
+ defaultReasoningEffort: string | undefined;
11
+ defaultPermissionMode: "workspace" | "readonly";
12
+ defaultExecutionMode: "locked" | "worktree";
13
+ permissionModes: ("workspace" | "readonly")[] | undefined;
14
+ executionModes: ("locked" | "worktree")[] | undefined;
15
+ };
16
+ export declare function codexAccountKey(identity: {
17
+ environmentId: string;
18
+ agentId: string;
19
+ }): string;
20
+ /** Preserve the standalone attach key so older attachment processes also exclude a new host. */
21
+ export declare function codexAccountLockKey(identity: {
22
+ environmentId: string;
23
+ agentId: string;
24
+ }): string;
25
+ export declare function codexNativeSessionLockKey(nativeSessionId: string): string;
26
+ /** Call while owning the account lock; malformed existing state must never get a new identity. */
27
+ export declare function resolveWorkSessionInstallation(directory: string, identity: {
28
+ environmentId: string;
29
+ agentId: string;
30
+ }): {
31
+ hostId: string;
32
+ journalPath: string;
33
+ };
@@ -0,0 +1,100 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { validateAttachedSessionEndpoint } from './attached-session-adapter.js';
6
+ export const CODEX_WORK_SESSION_HELP = `SHARED WORK SESSIONS (experimental; Codex 0.154 or newer)
7
+ canon-codex --shared-server ws://127.0.0.1:4500 --cwd /path/to/project
8
+
9
+ --shared-server <url> Explicit local WebSocket app-server
10
+ --profile <name> Canon profile (or use CANON_AGENT)
11
+ --cwd <path> Default permitted project
12
+ --workspace <path> Additional permitted project; repeatable
13
+ --workspace-root <path> Discover projects under a permitted root
14
+ --model <id> Default model from native model/list
15
+ --reasoning-effort <id> Default effort supported by that model
16
+ --default-permission-mode <mode> readonly or workspace (default workspace)
17
+ --permission-mode <mode> Allowed permission choices; repeatable
18
+ --default-execution-mode <mode> locked or worktree (default worktree)
19
+ --execution-mode <mode> Allowed execution choices; repeatable
20
+
21
+ In Canon choose Start or add work session. New sessions use on-request approval;
22
+ the account owner can answer native approvals and questions in Canon. Existing
23
+ loaded sessions preserve their native settings. Native clients can keep using
24
+ the same thread. Ctrl-C stops sharing without stopping the native server.
25
+ Desktop's private server is unsupported. Managed-host flags do not apply here.`;
26
+ export function isSharedWorkSessionCommand(argv) {
27
+ return argv.some((arg) => arg === '--shared-server' || arg.startsWith('--shared-server='));
28
+ }
29
+ export function parseCodexWorkSessionOptions(argv) {
30
+ const { values } = parseArgs({ args: argv, strict: true, allowPositionals: false, options: {
31
+ 'shared-server': { type: 'string' }, profile: { type: 'string' }, cwd: { type: 'string' },
32
+ workspace: { type: 'string', multiple: true }, 'workspace-root': { type: 'string', multiple: true },
33
+ model: { type: 'string' }, 'reasoning-effort': { type: 'string' },
34
+ 'default-permission-mode': { type: 'string' }, 'permission-mode': { type: 'string', multiple: true },
35
+ 'default-execution-mode': { type: 'string' }, 'execution-mode': { type: 'string', multiple: true },
36
+ } });
37
+ function clean(value, label) {
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (!value.trim() || /[\u0000-\u001f\u007f]/.test(value))
41
+ throw new Error(`${label} must be non-empty and contain no control characters.`);
42
+ return value.trim();
43
+ }
44
+ function choices(raw, allowed, label) {
45
+ if (!raw)
46
+ return undefined;
47
+ return [...new Set(raw.map((value) => {
48
+ if (!allowed.includes(value))
49
+ throw new Error(`${label} must be ${allowed.join(' or ')}.`);
50
+ return value;
51
+ }))];
52
+ }
53
+ const permissionModes = choices(values['permission-mode'], ['readonly', 'workspace'], '--permission-mode');
54
+ const executionModes = choices(values['execution-mode'], ['locked', 'worktree'], '--execution-mode');
55
+ const defaultPermissionMode = choices([values['default-permission-mode'] ?? 'workspace'], ['readonly', 'workspace'], '--default-permission-mode')[0];
56
+ const defaultExecutionMode = choices([values['default-execution-mode'] ?? 'worktree'], ['locked', 'worktree'], '--default-execution-mode')[0];
57
+ if ((permissionModes && !permissionModes.includes(defaultPermissionMode)) || (executionModes && !executionModes.includes(defaultExecutionMode))) {
58
+ throw new Error('Default work-session policies must be included in the allowed choices.');
59
+ }
60
+ return {
61
+ endpoint: validateAttachedSessionEndpoint(clean(values['shared-server'], '--shared-server') ?? ''),
62
+ profile: clean(values.profile, '--profile'), cwd: clean(values.cwd, '--cwd') ?? process.cwd(),
63
+ workspaces: values.workspace?.map((value) => clean(value, '--workspace')),
64
+ workspaceRoots: values['workspace-root']?.map((value) => clean(value, '--workspace-root')),
65
+ defaultModel: clean(values.model, '--model'), defaultReasoningEffort: clean(values['reasoning-effort'], '--reasoning-effort'),
66
+ defaultPermissionMode, defaultExecutionMode, permissionModes, executionModes,
67
+ };
68
+ }
69
+ export function codexAccountKey(identity) {
70
+ return createHash('sha256').update(JSON.stringify([identity.environmentId, identity.agentId])).digest('hex');
71
+ }
72
+ /** Preserve the standalone attach key so older attachment processes also exclude a new host. */
73
+ export function codexAccountLockKey(identity) {
74
+ return `attached-${codexAccountKey(identity)}`;
75
+ }
76
+ export function codexNativeSessionLockKey(nativeSessionId) {
77
+ return `attached-native-${createHash('sha256').update(JSON.stringify(['codex', nativeSessionId])).digest('hex')}`;
78
+ }
79
+ /** Call while owning the account lock; malformed existing state must never get a new identity. */
80
+ export function resolveWorkSessionInstallation(directory, identity) {
81
+ const stateDirectory = join(directory, 'work-session-hosts', codexAccountKey(identity));
82
+ mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
83
+ const path = join(stateDirectory, 'installation.json');
84
+ let value;
85
+ try {
86
+ value = JSON.parse(readFileSync(path, 'utf8'));
87
+ }
88
+ catch (error) {
89
+ if (error.code !== 'ENOENT')
90
+ throw new Error('Work-session installation identity cannot be read; retain its state for recovery.', { cause: error });
91
+ value = { schema: 'canon.codex-work-session-installation.v1', ...identity, hostId: randomUUID() };
92
+ writeFileSync(path, JSON.stringify(value), { flag: 'wx', mode: 0o600 });
93
+ }
94
+ const saved = value;
95
+ if (saved?.schema !== 'canon.codex-work-session-installation.v1' || saved.environmentId !== identity.environmentId
96
+ || saved.agentId !== identity.agentId || typeof saved.hostId !== 'string' || !/^[a-f0-9-]{36}$/.test(saved.hostId)) {
97
+ throw new Error('Work-session installation identity is invalid; retain its state for recovery.');
98
+ }
99
+ return { hostId: saved.hostId, journalPath: join(stateDirectory, 'journal.json') };
100
+ }
@@ -0,0 +1,40 @@
1
+ import { prepareConversationEnvironment, type NativeWorkSessionProvider, type NativeWorkSessionCreateContext, type NativeWorkSessionCreated, type NativeSessionInteractionBridge, type WorkSessionCatalog, type WorkSessionSelection } from '@canonmsg/core';
2
+ import { CodexAttachedSessionAdapter, type CodexSharedServerOptions } from './attached-session-adapter.js';
3
+ export interface CodexWorkSessionProviderOptions extends CodexSharedServerOptions {
4
+ cwd: string;
5
+ workspaces?: string[];
6
+ workspaceRoots?: string[];
7
+ defaultModel?: string;
8
+ defaultReasoningEffort?: string;
9
+ defaultPermissionMode?: 'readonly' | 'workspace';
10
+ defaultExecutionMode?: 'locked' | 'worktree';
11
+ /** Local policy ceiling. Never populated from a remote session request. */
12
+ permissionModes?: Array<'readonly' | 'workspace'>;
13
+ executionModes?: Array<'locked' | 'worktree'>;
14
+ /** Native worktrees remain on disk after the provider closes. */
15
+ prepareEnvironment?: typeof prepareConversationEnvironment;
16
+ }
17
+ /** One shared native server; all selections are resolved locally from the advertised catalog. */
18
+ export declare class CodexWorkSessionProvider implements NativeWorkSessionProvider {
19
+ private readonly options;
20
+ private readonly connection;
21
+ private readonly projects;
22
+ private readonly permissionModes;
23
+ private readonly executionModes;
24
+ private readonly observers;
25
+ private readonly environments;
26
+ private readonly createdSettings;
27
+ private readonly creationAttempts;
28
+ private readonly creationFingerprints;
29
+ private closed;
30
+ constructor(options: CodexWorkSessionProviderOptions);
31
+ catalog(): Promise<WorkSessionCatalog>;
32
+ create(selection: Extract<WorkSessionSelection, {
33
+ mode: 'create';
34
+ }>, context: NativeWorkSessionCreateContext): Promise<NativeWorkSessionCreated>;
35
+ private createOnce;
36
+ open(nativeSessionId: string, interactionBridge?: NativeSessionInteractionBridge): Promise<CodexAttachedSessionAdapter>;
37
+ close(): Promise<void>;
38
+ private assertOpen;
39
+ private models;
40
+ }
@@ -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.30.1",
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.3.0",
35
+ "@canonmsg/agent-tools": "^0.9.1",
36
+ "@canonmsg/coding-agent-host": "^0.7.1",
37
+ "@canonmsg/core": "^12.4.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
  }