@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.
package/dist/host.d.ts CHANGED
@@ -247,6 +247,11 @@ export declare function shouldRouteCodexTurnArtifacts(input: {
247
247
  artifactRoutingMode: TurnArtifactRoutingMode | undefined;
248
248
  silenced: boolean;
249
249
  finalText: string | null | undefined;
250
+ currentAudience?: {
251
+ memberIds?: readonly string[];
252
+ agentId: string;
253
+ ownerId: string | null | undefined;
254
+ };
250
255
  }): TurnArtifactRoutingDecision;
251
256
  export declare function main(): Promise<void>;
252
257
  export {};
package/dist/host.js CHANGED
@@ -6,7 +6,7 @@ import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
8
  import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
@@ -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
 
@@ -594,13 +599,21 @@ export function selectCodexNativeImagePaths(imagePaths, nativeVisionEnabled) {
594
599
  * does not currently carry into the catch without a race.
595
600
  */
596
601
  export function shouldRouteCodexTurnArtifacts(input) {
602
+ const audience = input.currentAudience;
603
+ const ownerPair = !audience || (audience.ownerId !== audience.agentId
604
+ && Boolean(audience.ownerId) && audience.memberIds?.length === 2
605
+ && audience.memberIds.includes(audience.agentId) && audience.memberIds.includes(audience.ownerId));
597
606
  return resolveTurnArtifactRouting({
598
- artifactRoutingMode: input.artifactRoutingMode,
607
+ artifactRoutingMode: ownerPair ? input.artifactRoutingMode : 'disabled',
599
608
  silenced: isSilentTurnSuppressed({ silenced: input.silenced, finalText: input.finalText }),
600
609
  });
601
610
  }
602
611
  export async function main() {
603
612
  setDefaultResultOrder('ipv4first');
613
+ if (isSharedWorkSessionCommand(process.argv.slice(2))) {
614
+ await runCodexWorkSessionHost(process.argv.slice(2));
615
+ return;
616
+ }
604
617
  const { values: args } = parseArgs({
605
618
  options: {
606
619
  cwd: { type: 'string' },
@@ -704,6 +717,9 @@ export async function main() {
704
717
  }
705
718
  console.error(`[canon-codex] Authenticated as ${agentId}`);
706
719
  }
720
+ // One local owner across managed, standalone attach and multi-room shared modes,
721
+ // including profile aliases and directly supplied credentials for this account.
722
+ managedAccountLock = acquireLock(codexAccountLockKey({ environmentId: resolvedAgent.environmentId, agentId }));
707
723
  let codexDynamicTools = filterCodexCommunicationTools(baseCodexDynamicTools, outboundPolicy);
708
724
  // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
709
725
  // create+poll (codex passes native/responder policy via payload/options).
@@ -842,6 +858,7 @@ export async function main() {
842
858
  if (cached) {
843
859
  conversationCache.set(payload.conversationId, {
844
860
  ...cached,
861
+ ...payload.changes,
845
862
  memberIds,
846
863
  });
847
864
  }
@@ -850,11 +867,12 @@ export async function main() {
850
867
  }
851
868
  if (!memberIds.includes(agentId)) {
852
869
  knownConversationIds.delete(payload.conversationId);
853
- conversationCache.delete(payload.conversationId);
870
+ pendingMembershipChanges.delete(payload.conversationId);
871
+ closeSession(payload.conversationId);
854
872
  }
855
873
  }
856
874
  function getGroupContextMode(conversationId, conversation) {
857
- if (conversation?.type !== 'group')
875
+ if (!conversation)
858
876
  return undefined;
859
877
  if (pendingMembershipChanges.has(conversationId))
860
878
  return 'membership_change';
@@ -872,7 +890,7 @@ export async function main() {
872
890
  }
873
891
  async function loadHydratedInboundContext(input) {
874
892
  const [conversation, page] = await Promise.all([
875
- getConversationMeta(input.conversationId),
893
+ getConversationMeta(input.conversationId, { refreshIfMemberMissing: agentId }),
876
894
  input.hydratedPage
877
895
  ? Promise.resolve(input.hydratedPage)
878
896
  : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
@@ -881,6 +899,7 @@ export async function main() {
881
899
  agentId,
882
900
  conversationId: input.conversationId,
883
901
  conversation,
902
+ requireAgentMembership: true,
884
903
  page,
885
904
  activeSelfContextId: input.activeSelfContextId,
886
905
  selfContexts: input.selfContexts,
@@ -1192,6 +1211,10 @@ export async function main() {
1192
1211
  }
1193
1212
  }
1194
1213
  async function getOrCreateSession(conversationId) {
1214
+ const current = conversationCache.get(conversationId);
1215
+ if (current && !current.memberIds.includes(agentId)) {
1216
+ throw new Error('Agent is no longer a member of this conversation');
1217
+ }
1195
1218
  knownConversationIds.add(conversationId);
1196
1219
  const existing = sessions.get(conversationId);
1197
1220
  if (existing && !existing.closed) {
@@ -1918,8 +1941,9 @@ export async function main() {
1918
1941
  replyAuthority: input.replyAuthority ?? null,
1919
1942
  });
1920
1943
  }
1921
- function sendTurnArtifactFile(session, file) {
1944
+ function sendTurnArtifactFile(session, file, canPublish) {
1922
1945
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1946
+ canPublish,
1923
1947
  ...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
1924
1948
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1925
1949
  metadata: {
@@ -1989,22 +2013,29 @@ export async function main() {
1989
2013
  // the collect-and-post loop, so there is no un-gated path left to the
1990
2014
  // workspace: the failure branches still send Canon's notice, and none of
1991
2015
  // them can post the artifacts of a turn that chose silence.
2016
+ const decideArtifactRouting = () => shouldRouteCodexTurnArtifacts({
2017
+ artifactRoutingMode: nextTurn.artifactRoutingMode,
2018
+ silenced: session.currentTurnSilenced,
2019
+ finalText: turnFinalText,
2020
+ currentAudience: { memberIds: conversationCache.get(session.conversationId)?.memberIds, agentId, ownerId },
2021
+ });
1992
2022
  const artifactRouter = createTurnArtifactRouter({
1993
- decide: () => shouldRouteCodexTurnArtifacts({
1994
- artifactRoutingMode: nextTurn.artifactRoutingMode,
1995
- silenced: session.currentTurnSilenced,
1996
- finalText: turnFinalText,
1997
- }),
2023
+ decide: decideArtifactRouting,
1998
2024
  baseline: () => artifactBaseline,
1999
2025
  cwd: () => session.cwd,
2000
- send: (file) => sendTurnArtifactFile(session, file),
2026
+ send: (file) => sendTurnArtifactFile(session, file, () => decideArtifactRouting().route),
2001
2027
  log: (line) => console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`),
2002
2028
  });
2003
2029
  const routeArtifactsOnce = artifactRouter.route;
2004
2030
  try {
2005
2031
  const turnId = session.currentTurnId ?? randomUUID();
2006
2032
  session.currentTurnId = turnId;
2007
- let turnPrompt = nextTurn.prompt;
2033
+ // Queueing does not freeze the audience: refresh at the native turn
2034
+ // boundary while retaining the same thread and its existing memory.
2035
+ const roster = buildCanonGroupContext({
2036
+ conversation: conversationCache.get(session.conversationId), messages: [], agentId, ownerId, ownerName,
2037
+ });
2038
+ let turnPrompt = [nextTurn.prompt, ...(roster ? buildCompactGroupContextLines(roster, 'initial') : [])].join('\n\n');
2008
2039
  if (nextTurn.artifactRoutingMode === 'workspace-generated') {
2009
2040
  artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
2010
2041
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
@@ -2820,6 +2851,7 @@ export async function main() {
2820
2851
  closeSession(session.conversationId);
2821
2852
  }
2822
2853
  markLocalRuntimeStopped(runtimeId);
2854
+ managedAccountLock?.release();
2823
2855
  (lockHandle ?? getActiveProfileLock())?.release();
2824
2856
  process.exit(0);
2825
2857
  };
@@ -2832,6 +2864,7 @@ export async function main() {
2832
2864
  runCli(import.meta.url, main, (error) => {
2833
2865
  const message = error instanceof Error ? error.message : String(error);
2834
2866
  console.error(`[canon-codex] ${message}`);
2867
+ managedAccountLock?.release();
2835
2868
  getActiveProfileLock()?.release();
2836
2869
  process.exit(1);
2837
2870
  }, {
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
+ }