@canonmsg/codex-plugin 0.7.0 → 0.9.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/README.md CHANGED
@@ -37,7 +37,15 @@ You do not need a git repo for host mode. The plugin passes `--skip-git-repo-che
37
37
 
38
38
  ## Current limitation
39
39
 
40
- The stable `codex exec --json` surface exposes thinking state, tool activity, and completed assistant-message previews, but not token-by-token text deltas. v1 therefore publishes live progress and message snapshots without claiming true token streaming.
40
+ The stable `codex exec --json` surface exposes thinking state, tool activity, and completed assistant-message previews, but not token-by-token text deltas. v1 therefore publishes live progress and assistant-message snapshots without claiming true token streaming.
41
+
42
+ Current Canon control truth for Codex host mode:
43
+
44
+ - model is live-visible, but current changes apply on the next turn rather than mid-turn
45
+ - workspace selection is setup-only
46
+ - execution mode selection is setup-only
47
+ - the current `Execution policy`/permission choice is setup-only
48
+ - advanced Codex-only controls such as effort, sandbox policy, approval reviewer, or apps/plugins inventory are not exposed on the current transport unless the runtime can actually report them
41
49
 
42
50
  ## Working directory
43
51
 
@@ -48,10 +56,12 @@ canon-codex --cwd /path/to/project
48
56
  Advertise multiple project choices to the Canon app:
49
57
 
50
58
  ```bash
51
- canon-codex --cwd ~/dev --workspace ~/dev/canon --workspace ~/dev/yumyumv2
59
+ canon-codex --cwd ~/dev --workspace-root ~/dev
52
60
  ```
53
61
 
54
- `--cwd` is the default workspace. Each `--workspace` value appears as a selectable workspace during session creation. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-workspace mode runs directly in the selected directory.
62
+ `--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
63
+
64
+ If worktree isolation is requested for a project that cannot support it, Canon may fall back to shared-project execution and surface the fallback reason in session details instead of failing the session outright.
55
65
 
56
66
  Useful flags:
57
67
 
package/dist/host.js CHANGED
@@ -3,7 +3,7 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
6
- import { buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, writeRuntimeInfo, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
6
+ import { buildConfiguredWorkspaceOptionsWithRoots, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, writeRuntimeInfo, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
7
7
  import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
8
8
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
9
  import { CodexConversationAdapter, } from './adapter.js';
@@ -23,6 +23,8 @@ const CODEX_RUNTIME_CAPABILITIES = {
23
23
  };
24
24
  let workingDir = process.cwd();
25
25
  let workspaceOptions = [];
26
+ let workspaceRoots = [];
27
+ let workspaceRootMetadata = [];
26
28
  function buildCodexRuntimeDescriptor(input) {
27
29
  return {
28
30
  coreControls: [
@@ -37,25 +39,32 @@ function buildCodexRuntimeDescriptor(input) {
37
39
  },
38
40
  {
39
41
  id: 'workspace',
40
- label: 'Workspace',
42
+ label: 'Project',
41
43
  options: input.workspaces.map((workspace) => ({
42
44
  value: workspace.id,
43
45
  label: workspace.label,
46
+ ...(workspace.description ? { description: workspace.description } : {}),
47
+ ...(workspace.workspaceRootId ? { workspaceRootId: workspace.workspaceRootId } : {}),
48
+ ...(workspace.workspaceRelativePath ? { workspaceRelativePath: workspace.workspaceRelativePath } : {}),
49
+ ...(workspace.source ? { source: workspace.source } : {}),
44
50
  })),
45
51
  defaultValue: input.workspaces[0]?.id ?? null,
46
52
  availability: 'setup',
47
53
  liveBehavior: 'none',
48
54
  selectionPolicy: 'inherit',
55
+ description: input.workspaceRoots?.length
56
+ ? 'Choose one of the projects discovered inside the approved local roots for this host.'
57
+ : 'Choose one of the local projects advertised by this host.',
49
58
  },
50
59
  {
51
60
  id: 'executionMode',
52
61
  label: 'Execution mode',
53
62
  options: input.executionModes.map((mode) => ({
54
63
  value: mode,
55
- label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared workspace',
64
+ label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared project',
56
65
  description: mode === 'worktree'
57
- ? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected workspace is a git repo.'
58
- : 'Runs directly in the selected workspace. Canon records usage, but does not create a separate checkout.',
66
+ ? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected project is a git repo.'
67
+ : 'Runs directly in the selected project folder. Changes happen there.',
59
68
  })),
60
69
  defaultValue: null,
61
70
  availability: 'setup',
@@ -74,6 +83,29 @@ function buildCodexRuntimeDescriptor(input) {
74
83
  selectionPolicy: 'inherit',
75
84
  },
76
85
  ],
86
+ actions: [
87
+ {
88
+ id: 'stop',
89
+ label: 'Stop',
90
+ description: 'Interrupt the current Codex exec turn.',
91
+ aliases: ['stop'],
92
+ category: 'turn',
93
+ placements: ['composer_slash', 'command_palette'],
94
+ availability: ['busy'],
95
+ dispatch: { kind: 'signal', signal: 'interrupt' },
96
+ },
97
+ {
98
+ id: 'stop-and-clear-queue',
99
+ label: 'Stop & clear queue',
100
+ description: 'Interrupt the current Codex exec turn and drop queued Canon messages.',
101
+ aliases: ['stop-clear', 'clear-queue'],
102
+ category: 'turn',
103
+ placements: ['composer_slash', 'command_palette', 'session_strip'],
104
+ availability: ['busy_with_queue'],
105
+ dispatch: { kind: 'signal', signal: 'stop_and_drop' },
106
+ },
107
+ ],
108
+ workspaceRoots: input.workspaceRoots,
77
109
  supportsInterrupt: true,
78
110
  streamingTextMode: 'snapshot',
79
111
  };
@@ -104,11 +136,10 @@ async function loadSessionConfig(conversationId, agentId) {
104
136
  extraStringFields: ['permissionMode'],
105
137
  });
106
138
  }
107
- // Default to 'locked' (shared workspace) when no mode has been picked. The
108
- // UI still lets owners flip to 'worktree'; this just stops sessions from
109
- // failing closed when the mode has never been written.
110
139
  function resolveSessionExecutionMode(config) {
111
- return config?.executionMode ?? 'locked';
140
+ if (config?.executionMode)
141
+ return config.executionMode;
142
+ throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
112
143
  }
113
144
  function resolveWorkspaceCwd(config) {
114
145
  return resolveHostWorkspaceCwd({
@@ -164,6 +195,7 @@ export async function main() {
164
195
  'codex-profile': { type: 'string' },
165
196
  'add-dir': { type: 'string', multiple: true },
166
197
  workspace: { type: 'string', multiple: true },
198
+ 'workspace-root': { type: 'string', multiple: true },
167
199
  config: { type: 'string', multiple: true },
168
200
  'codex-bin': { type: 'string' },
169
201
  'full-auto': { type: 'boolean' },
@@ -172,7 +204,17 @@ export async function main() {
172
204
  strict: true,
173
205
  });
174
206
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
175
- workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, args.workspace ?? []);
207
+ const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
208
+ primaryCwd: workingDir,
209
+ configuredWorkspaces: args.workspace ?? [],
210
+ workspaceRoots: args['workspace-root'] ?? [],
211
+ });
212
+ workspaceOptions = workspaceDiscovery.workspaceOptions;
213
+ workspaceRoots = workspaceDiscovery.workspaceRoots;
214
+ workspaceRootMetadata = buildPublicWorkspaceRoots(workspaceRoots);
215
+ for (const warning of workspaceDiscovery.warnings) {
216
+ console.error(`[canon-codex] ${warning}`);
217
+ }
176
218
  if (typeof args['ask-for-approval'] === 'string') {
177
219
  console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
178
220
  }
@@ -258,6 +300,9 @@ export async function main() {
258
300
  executionMode: session.environment.mode,
259
301
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
260
302
  ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
303
+ ...(resolveExecutionFallbackReason(session.environment)
304
+ ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
305
+ : {}),
261
306
  hostMode: true,
262
307
  clientType: 'codex',
263
308
  state: session.state.state,
@@ -291,11 +336,33 @@ export async function main() {
291
336
  clearStreaming(conversationId);
292
337
  client.setTyping(conversationId, false).catch(() => { });
293
338
  }
339
+ function refreshVisibleWorkSignal(session) {
340
+ if (!session.running || session.closed)
341
+ return;
342
+ if (session.turnState !== 'thinking' && session.turnState !== 'tool')
343
+ return;
344
+ client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
345
+ }
346
+ function startVisibleWorkSignal(session) {
347
+ refreshVisibleWorkSignal(session);
348
+ if (session.typingKeepaliveTimer)
349
+ return;
350
+ session.typingKeepaliveTimer = setInterval(() => {
351
+ refreshVisibleWorkSignal(session);
352
+ }, 3500);
353
+ }
354
+ function stopVisibleWorkSignal(session) {
355
+ if (session.typingKeepaliveTimer) {
356
+ clearInterval(session.typingKeepaliveTimer);
357
+ session.typingKeepaliveTimer = null;
358
+ }
359
+ }
294
360
  function closeSession(conversationId) {
295
361
  const session = sessions.get(conversationId);
296
362
  if (!session)
297
363
  return;
298
364
  session.closed = true;
365
+ stopVisibleWorkSignal(session);
299
366
  releaseConversationEnvironment(session.environment);
300
367
  clearStreaming(conversationId);
301
368
  clearSessionState(conversationId, agentId).catch(() => { });
@@ -383,6 +450,7 @@ export async function main() {
383
450
  currentTurnOpenedAt: null,
384
451
  lastAcceptedIntent: null,
385
452
  lastActivity: Date.now(),
453
+ typingKeepaliveTimer: null,
386
454
  closed: false,
387
455
  };
388
456
  sessions.set(conversationId, session);
@@ -462,7 +530,12 @@ export async function main() {
462
530
  const message = error instanceof Error ? error.message : String(error);
463
531
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
464
532
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
465
- await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
533
+ await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
534
+ metadata: {
535
+ turnSemantics: 'turn_complete',
536
+ turnComplete: true,
537
+ },
538
+ }).catch(() => { });
466
539
  return;
467
540
  }
468
541
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
@@ -503,7 +576,7 @@ export async function main() {
503
576
  await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
504
577
  writeState(session);
505
578
  writeTurn(session);
506
- client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
579
+ startVisibleWorkSignal(session);
507
580
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
508
581
  text: 'Thinking…',
509
582
  status: 'thinking',
@@ -521,6 +594,8 @@ export async function main() {
521
594
  if (event.type === 'message') {
522
595
  session.turnState = 'streaming';
523
596
  writeTurn(session);
597
+ stopVisibleWorkSignal(session);
598
+ client.setTyping(session.conversationId, false).catch(() => { });
524
599
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
525
600
  text: event.text,
526
601
  status: 'streaming',
@@ -531,7 +606,7 @@ export async function main() {
531
606
  if (event.type === 'command.started') {
532
607
  session.turnState = 'tool';
533
608
  writeTurn(session);
534
- client.setTyping(session.conversationId, false).catch(() => { });
609
+ startVisibleWorkSignal(session);
535
610
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
536
611
  text: summarizeCommand(event.command),
537
612
  status: 'tool',
@@ -583,6 +658,7 @@ export async function main() {
583
658
  else if (result.interrupted) {
584
659
  session.turnState = 'interrupted';
585
660
  writeTurn(session);
661
+ stopVisibleWorkSignal(session);
586
662
  clearStreaming(session.conversationId);
587
663
  client.setTyping(session.conversationId, false).catch(() => { });
588
664
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
@@ -605,6 +681,7 @@ export async function main() {
605
681
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
606
682
  }
607
683
  finally {
684
+ stopVisibleWorkSignal(session);
608
685
  session.running = false;
609
686
  session.state.state = 'idle';
610
687
  session.turnState = 'idle';
@@ -637,6 +714,7 @@ export async function main() {
637
714
  runtimeDescriptor: buildCodexRuntimeDescriptor({
638
715
  models: [],
639
716
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
717
+ workspaceRoots: workspaceRootMetadata,
640
718
  executionModes: hostAvailableExecutionModes,
641
719
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
642
720
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -683,6 +761,7 @@ export async function main() {
683
761
  descriptor: runtimeDescriptor.runtimeDescriptor ?? buildCodexRuntimeDescriptor({
684
762
  models: runtimeDescriptor.availableModels ?? [],
685
763
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
764
+ workspaceRoots: workspaceRootMetadata,
686
765
  executionModes: hostAvailableExecutionModes,
687
766
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
688
767
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -699,10 +778,18 @@ export async function main() {
699
778
  label: 'Live output',
700
779
  value: 'Thinking, tools, and completed-message previews',
701
780
  },
781
+ {
782
+ id: 'nativeActions',
783
+ label: 'Native actions',
784
+ value: 'Limited until app-server transport',
785
+ tone: 'warning',
786
+ },
702
787
  ],
703
788
  execution: {
704
789
  resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
705
790
  resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
791
+ workspaceRootId: workspace?.workspaceRootId ?? null,
792
+ workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
706
793
  executionMode: session?.environment.mode ?? null,
707
794
  executionBranch: session?.environment.branch ?? null,
708
795
  worktreePath: session?.environment.worktreePath ?? null,
@@ -710,6 +797,7 @@ export async function main() {
710
797
  },
711
798
  notes: [
712
799
  'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not token-by-token text deltas.',
800
+ 'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
713
801
  ],
714
802
  };
715
803
  await writeRuntimeInfo(conversationId, agentId, payload);
@@ -760,6 +848,7 @@ export async function main() {
760
848
  runtimeDescriptor: buildCodexRuntimeDescriptor({
761
849
  models: [],
762
850
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
851
+ workspaceRoots: workspaceRootMetadata,
763
852
  executionModes: hostAvailableExecutionModes,
764
853
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
765
854
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -778,6 +867,7 @@ export async function main() {
778
867
  runtimeDescriptor: buildCodexRuntimeDescriptor({
779
868
  models: [],
780
869
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
870
+ workspaceRoots: workspaceRootMetadata,
781
871
  executionModes: hostAvailableExecutionModes,
782
872
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
783
873
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
package/dist/setup.js CHANGED
@@ -21,8 +21,10 @@ export function main() {
21
21
  console.log('');
22
22
  console.log(' 2. Start the host in a project directory and keep it running');
23
23
  console.log(' canon-codex --cwd /path/to/project');
24
+ console.log(' canon-codex --cwd ~/dev --workspace-root ~/dev');
24
25
  console.log('');
25
26
  console.log(' A git repo is not required; any readable directory works.');
27
+ console.log(' Use --workspace-root to let Canon offer discovered projects inside an approved root.');
26
28
  console.log('');
27
29
  console.log('Optional flags:');
28
30
  console.log(' --model gpt-5.4');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^0.9.0",
33
- "@canonmsg/core": "^0.8.0"
32
+ "@canonmsg/agent-sdk": "^0.9.2",
33
+ "@canonmsg/core": "^0.10.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"