@canonmsg/codex-plugin 0.7.0 → 0.9.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/README.md CHANGED
@@ -22,7 +22,15 @@ canon-codex --cwd /path/to/project
22
22
 
23
23
  Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
24
24
 
25
- If the terminal closes or the machine restarts, the agent goes offline until you start the host again. To bring back the same registered agent, rerun `canon-codex --cwd /path/to/project`. Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`.
25
+ If the terminal closes or the machine restarts, the agent goes offline until you start the host again. Install `@canonmsg/local-agents` and run `canon-necromance` to list every recorded local agent from newest to oldest, then revive one in the foreground:
26
+
27
+ ```bash
28
+ npm install -g @canonmsg/local-agents
29
+ canon-necromance
30
+ canon-necromance revive my-codex
31
+ ```
32
+
33
+ Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`. Keep the revived terminal open; closing it takes this local agent offline.
26
34
 
27
35
  You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
28
36
 
@@ -37,7 +45,15 @@ You do not need a git repo for host mode. The plugin passes `--skip-git-repo-che
37
45
 
38
46
  ## Current limitation
39
47
 
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.
48
+ 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.
49
+
50
+ Current Canon control truth for Codex host mode:
51
+
52
+ - model is live-visible, but current changes apply on the next turn rather than mid-turn
53
+ - workspace selection is setup-only
54
+ - execution mode selection is setup-only
55
+ - the current `Execution policy`/permission choice is setup-only
56
+ - 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
57
 
42
58
  ## Working directory
43
59
 
@@ -48,10 +64,12 @@ canon-codex --cwd /path/to/project
48
64
  Advertise multiple project choices to the Canon app:
49
65
 
50
66
  ```bash
51
- canon-codex --cwd ~/dev --workspace ~/dev/canon --workspace ~/dev/yumyumv2
67
+ canon-codex --cwd ~/dev --workspace-root ~/dev
52
68
  ```
53
69
 
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.
70
+ `--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.
71
+
72
+ 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
73
 
56
74
  Useful flags:
57
75
 
@@ -89,7 +107,7 @@ If `canon-codex` starts but cannot find the `codex` binary, either fix your `PAT
89
107
  canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
90
108
  ```
91
109
 
92
- If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Rerun registration for the same profile to overwrite `~/.canon/agents.json`, then restart the host:
110
+ If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Reconnect the same profile to overwrite `~/.canon/agents.json`, then restart or revive the host:
93
111
 
94
112
  ```bash
95
113
  canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
@@ -68,8 +68,6 @@ export function renderCanonHostInboundContent(message, materialized) {
68
68
  }
69
69
  function describeContactCard(card) {
70
70
  const parts = [`${card.userType} · userId: ${card.userId}`];
71
- if (card.accessLevel)
72
- parts.push(`access: ${card.accessLevel}`);
73
71
  if (card.ownerName)
74
72
  parts.push(`owner: ${card.ownerName}`);
75
73
  if (card.about)
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, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, writeRuntimeInfo, shouldTriggerAgentTurn, saveRuntimeSessionState, writeSessionState, writeTurnState, upsertLocalRuntimeEntry, } 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,13 +204,23 @@ 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
  }
179
- const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
221
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
180
222
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
181
- const client = new CanonClient(apiKey);
223
+ const client = new CanonClient(apiKey, baseUrl);
182
224
  initRTDBAuth(client);
183
225
  let agentId;
184
226
  let ownerId = null;
@@ -198,6 +240,34 @@ export async function main() {
198
240
  }
199
241
  console.error(`[canon-codex] Authenticated as ${agentId}`);
200
242
  }
243
+ const launchArgs = [...process.argv.slice(2)];
244
+ if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
245
+ launchArgs.push('--cwd', workingDir);
246
+ }
247
+ const runtimeId = buildLocalRuntimeId({
248
+ runtime: 'codex',
249
+ profile,
250
+ cwd: workingDir,
251
+ launchCommand: ['canon-codex', ...launchArgs],
252
+ });
253
+ upsertLocalRuntimeEntry({
254
+ id: runtimeId,
255
+ runtime: 'codex',
256
+ profile,
257
+ agentId,
258
+ agentName: profileAgentName,
259
+ cwd: workingDir,
260
+ baseCwd: workingDir,
261
+ workspaceRoots: workspaceRoots.map((root) => root.cwd),
262
+ workspaces: workspaceOptions.map((workspace) => workspace.cwd),
263
+ launchCommand: ['canon-codex', ...launchArgs],
264
+ pid: process.pid,
265
+ status: profile ? 'running' : 'manual',
266
+ reviveCapability: profile ? 'revivable' : 'manual',
267
+ surfaceMode: 'host',
268
+ lastStartedAt: new Date().toISOString(),
269
+ lastHeartbeatAt: new Date().toISOString(),
270
+ });
201
271
  const sessions = new Map();
202
272
  const pendingSessionCreations = new Map();
203
273
  const conversationCache = new Map();
@@ -258,6 +328,9 @@ export async function main() {
258
328
  executionMode: session.environment.mode,
259
329
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
260
330
  ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
331
+ ...(resolveExecutionFallbackReason(session.environment)
332
+ ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
333
+ : {}),
261
334
  hostMode: true,
262
335
  clientType: 'codex',
263
336
  state: session.state.state,
@@ -291,11 +364,33 @@ export async function main() {
291
364
  clearStreaming(conversationId);
292
365
  client.setTyping(conversationId, false).catch(() => { });
293
366
  }
367
+ function refreshVisibleWorkSignal(session) {
368
+ if (!session.running || session.closed)
369
+ return;
370
+ if (session.turnState !== 'thinking' && session.turnState !== 'tool')
371
+ return;
372
+ client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
373
+ }
374
+ function startVisibleWorkSignal(session) {
375
+ refreshVisibleWorkSignal(session);
376
+ if (session.typingKeepaliveTimer)
377
+ return;
378
+ session.typingKeepaliveTimer = setInterval(() => {
379
+ refreshVisibleWorkSignal(session);
380
+ }, 3500);
381
+ }
382
+ function stopVisibleWorkSignal(session) {
383
+ if (session.typingKeepaliveTimer) {
384
+ clearInterval(session.typingKeepaliveTimer);
385
+ session.typingKeepaliveTimer = null;
386
+ }
387
+ }
294
388
  function closeSession(conversationId) {
295
389
  const session = sessions.get(conversationId);
296
390
  if (!session)
297
391
  return;
298
392
  session.closed = true;
393
+ stopVisibleWorkSignal(session);
299
394
  releaseConversationEnvironment(session.environment);
300
395
  clearStreaming(conversationId);
301
396
  clearSessionState(conversationId, agentId).catch(() => { });
@@ -342,7 +437,7 @@ export async function main() {
342
437
  try {
343
438
  const sessionCwd = environment.cwd;
344
439
  const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
345
- const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
440
+ const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode);
346
441
  if (config?.permissionMode
347
442
  && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
348
443
  throw new ExecutionEnvironmentError(`Permission mode "${config.permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
@@ -383,6 +478,7 @@ export async function main() {
383
478
  currentTurnOpenedAt: null,
384
479
  lastAcceptedIntent: null,
385
480
  lastActivity: Date.now(),
481
+ typingKeepaliveTimer: null,
386
482
  closed: false,
387
483
  };
388
484
  sessions.set(conversationId, session);
@@ -462,7 +558,12 @@ export async function main() {
462
558
  const message = error instanceof Error ? error.message : String(error);
463
559
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
464
560
  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(() => { });
561
+ await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
562
+ metadata: {
563
+ turnSemantics: 'turn_complete',
564
+ turnComplete: true,
565
+ },
566
+ }).catch(() => { });
466
567
  return;
467
568
  }
468
569
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
@@ -503,7 +604,7 @@ export async function main() {
503
604
  await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
504
605
  writeState(session);
505
606
  writeTurn(session);
506
- client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
607
+ startVisibleWorkSignal(session);
507
608
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
508
609
  text: 'Thinking…',
509
610
  status: 'thinking',
@@ -514,13 +615,15 @@ export async function main() {
514
615
  const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
515
616
  session.lastActivity = Date.now();
516
617
  if (event.type === 'thread.started') {
517
- saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
618
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode);
518
619
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
519
620
  return;
520
621
  }
521
622
  if (event.type === 'message') {
522
623
  session.turnState = 'streaming';
523
624
  writeTurn(session);
625
+ stopVisibleWorkSignal(session);
626
+ client.setTyping(session.conversationId, false).catch(() => { });
524
627
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
525
628
  text: event.text,
526
629
  status: 'streaming',
@@ -531,7 +634,7 @@ export async function main() {
531
634
  if (event.type === 'command.started') {
532
635
  session.turnState = 'tool';
533
636
  writeTurn(session);
534
- client.setTyping(session.conversationId, false).catch(() => { });
637
+ startVisibleWorkSignal(session);
535
638
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
536
639
  text: summarizeCommand(event.command),
537
640
  status: 'tool',
@@ -546,7 +649,7 @@ export async function main() {
546
649
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
547
650
  }, turnImagePaths);
548
651
  if (result.threadId) {
549
- saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
652
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode);
550
653
  }
551
654
  if (!result.interrupted && result.finalMessage) {
552
655
  await client.sendMessage(session.conversationId, result.finalMessage, {
@@ -583,6 +686,7 @@ export async function main() {
583
686
  else if (result.interrupted) {
584
687
  session.turnState = 'interrupted';
585
688
  writeTurn(session);
689
+ stopVisibleWorkSignal(session);
586
690
  clearStreaming(session.conversationId);
587
691
  client.setTyping(session.conversationId, false).catch(() => { });
588
692
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
@@ -601,10 +705,13 @@ export async function main() {
601
705
  },
602
706
  }).catch(() => { });
603
707
  await handoffFinalMessage(session.conversationId);
604
- clearStoredThreadId(agentId, session.conversationId);
708
+ if (error instanceof Error && /invalid|not found|unknown thread/i.test(error.message)) {
709
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
710
+ }
605
711
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
606
712
  }
607
713
  finally {
714
+ stopVisibleWorkSignal(session);
608
715
  session.running = false;
609
716
  session.state.state = 'idle';
610
717
  session.turnState = 'idle';
@@ -637,12 +744,19 @@ export async function main() {
637
744
  runtimeDescriptor: buildCodexRuntimeDescriptor({
638
745
  models: [],
639
746
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
747
+ workspaceRoots: workspaceRootMetadata,
640
748
  executionModes: hostAvailableExecutionModes,
641
749
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
642
750
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
643
751
  }),
644
752
  };
645
753
  const publishRuntimeHeartbeat = async () => {
754
+ heartbeatLocalRuntimeEntry(runtimeId, {
755
+ agentId,
756
+ agentName: profileAgentName,
757
+ cwd: workingDir,
758
+ baseCwd: workingDir,
759
+ });
646
760
  if (!streamConnected)
647
761
  return;
648
762
  await refreshKnownConversationIds().catch((error) => {
@@ -683,6 +797,7 @@ export async function main() {
683
797
  descriptor: runtimeDescriptor.runtimeDescriptor ?? buildCodexRuntimeDescriptor({
684
798
  models: runtimeDescriptor.availableModels ?? [],
685
799
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
800
+ workspaceRoots: workspaceRootMetadata,
686
801
  executionModes: hostAvailableExecutionModes,
687
802
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
688
803
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -699,10 +814,18 @@ export async function main() {
699
814
  label: 'Live output',
700
815
  value: 'Thinking, tools, and completed-message previews',
701
816
  },
817
+ {
818
+ id: 'nativeActions',
819
+ label: 'Native actions',
820
+ value: 'Limited until app-server transport',
821
+ tone: 'warning',
822
+ },
702
823
  ],
703
824
  execution: {
704
825
  resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
705
826
  resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
827
+ workspaceRootId: workspace?.workspaceRootId ?? null,
828
+ workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
706
829
  executionMode: session?.environment.mode ?? null,
707
830
  executionBranch: session?.environment.branch ?? null,
708
831
  worktreePath: session?.environment.worktreePath ?? null,
@@ -710,6 +833,7 @@ export async function main() {
710
833
  },
711
834
  notes: [
712
835
  '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.',
836
+ '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
837
  ],
714
838
  };
715
839
  await writeRuntimeInfo(conversationId, agentId, payload);
@@ -733,6 +857,13 @@ export async function main() {
733
857
  behavior: payload.behavior,
734
858
  workSessions: payload.workSessions,
735
859
  });
860
+ if (message.id) {
861
+ saveRuntimeSessionState(runtimeId, {
862
+ conversationId: payload.conversationId,
863
+ baseCwd: workingDir,
864
+ lastInboundMessageId: message.id,
865
+ });
866
+ }
736
867
  },
737
868
  onConnected: () => {
738
869
  streamConnected = true;
@@ -760,6 +891,7 @@ export async function main() {
760
891
  runtimeDescriptor: buildCodexRuntimeDescriptor({
761
892
  models: [],
762
893
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
894
+ workspaceRoots: workspaceRootMetadata,
763
895
  executionModes: hostAvailableExecutionModes,
764
896
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
765
897
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -778,6 +910,7 @@ export async function main() {
778
910
  runtimeDescriptor: buildCodexRuntimeDescriptor({
779
911
  models: [],
780
912
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
913
+ workspaceRoots: workspaceRootMetadata,
781
914
  executionModes: hostAvailableExecutionModes,
782
915
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
783
916
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
@@ -795,34 +928,51 @@ export async function main() {
795
928
  clearTurnState(conversation.id, agentId).catch(() => { });
796
929
  }
797
930
  for (const conversation of conversations) {
798
- if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
799
- continue;
800
- const latestPage = await client.getMessagesPage(conversation.id, 1);
801
- const latestMessage = latestPage.messages[0];
802
- if (!latestMessage || latestMessage.senderId === agentId)
803
- continue;
804
- const senderTurnState = latestMessage.senderType === 'ai_agent'
805
- ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
806
- : null;
807
- const triggerDecision = shouldTriggerAgentTurn({
808
- senderType: latestMessage.senderType ?? 'human',
809
- metadata: latestMessage.metadata,
810
- senderTurnState,
811
- });
812
- if (!triggerDecision.allow) {
813
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
814
- continue;
815
- }
816
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
817
- await enqueueInboundMessage({
931
+ const cursor = loadRuntimeSessionState(runtimeId, {
818
932
  conversationId: conversation.id,
819
- message: latestMessage,
820
- senderName: latestMessage.senderId,
821
- isOwner: ownerId != null && latestMessage.senderId === ownerId,
822
- behavior: latestPage.behavior,
823
- workSessions: latestPage.workSessions,
824
- hydratedPage: latestPage,
825
- });
933
+ baseCwd: workingDir,
934
+ })?.lastInboundMessageId;
935
+ const latestPage = await client.getMessagesPage(conversation.id, 25);
936
+ const inboundMessages = latestPage.messages
937
+ .filter((message) => message.senderId !== agentId)
938
+ .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
939
+ const cursorIndex = cursor
940
+ ? inboundMessages.findIndex((message) => message.id === cursor)
941
+ : -1;
942
+ const messagesToRecover = cursorIndex >= 0
943
+ ? inboundMessages.slice(cursorIndex + 1)
944
+ : inboundMessages.slice(-1);
945
+ for (const latestMessage of messagesToRecover) {
946
+ const senderTurnState = latestMessage.senderType === 'ai_agent'
947
+ ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
948
+ : null;
949
+ const triggerDecision = shouldTriggerAgentTurn({
950
+ senderType: latestMessage.senderType ?? 'human',
951
+ metadata: latestMessage.metadata,
952
+ senderTurnState,
953
+ });
954
+ if (!triggerDecision.allow) {
955
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
956
+ continue;
957
+ }
958
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
959
+ await enqueueInboundMessage({
960
+ conversationId: conversation.id,
961
+ message: latestMessage,
962
+ senderName: latestMessage.senderId,
963
+ isOwner: ownerId != null && latestMessage.senderId === ownerId,
964
+ behavior: latestPage.behavior,
965
+ workSessions: latestPage.workSessions,
966
+ hydratedPage: latestPage,
967
+ });
968
+ if (latestMessage.id) {
969
+ saveRuntimeSessionState(runtimeId, {
970
+ conversationId: conversation.id,
971
+ baseCwd: workingDir,
972
+ lastInboundMessageId: latestMessage.id,
973
+ });
974
+ }
975
+ }
826
976
  }
827
977
  }
828
978
  catch (error) {
@@ -921,20 +1071,18 @@ export async function main() {
921
1071
  await session.adapter.interrupt().catch(() => { });
922
1072
  closeSession(session.conversationId);
923
1073
  }
924
- const activeProfile = getActiveProfile();
925
- if (activeProfile)
926
- releaseLock(activeProfile);
1074
+ markLocalRuntimeStopped(runtimeId);
1075
+ (lockHandle ?? getActiveProfileLock())?.release();
927
1076
  process.exit(0);
928
1077
  };
929
1078
  process.on('SIGINT', shutdown);
930
1079
  process.on('SIGTERM', shutdown);
1080
+ process.on('SIGHUP', shutdown);
931
1081
  console.error('[canon-codex] Ready — sessions created on demand');
932
1082
  await new Promise(() => { });
933
1083
  }
934
1084
  runCli(import.meta.url, main, (error) => {
935
1085
  console.error('[canon-codex] Fatal error:', error);
936
- const activeProfile = getActiveProfile();
937
- if (activeProfile)
938
- releaseLock(activeProfile);
1086
+ getActiveProfileLock()?.release();
939
1087
  process.exit(1);
940
1088
  });
package/dist/register.js CHANGED
@@ -1,13 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
6
4
  import { parseArgs } from 'node:util';
7
- import { registerAndWaitForApproval } from '@canonmsg/core';
5
+ import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
8
6
  import { runCli } from './cli-entry.js';
9
- const CANON_DIR = join(homedir(), '.canon');
10
- const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
7
  export async function main() {
12
8
  setDefaultResultOrder('ipv4first');
13
9
  const { values } = parseArgs({
@@ -34,6 +30,7 @@ export async function main() {
34
30
  // No existing profile state.
35
31
  }
36
32
  console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
33
+ const pending = getOrCreatePendingRegistration(profileName, 'codex');
37
34
  const result = await registerAndWaitForApproval({
38
35
  name: values.name,
39
36
  description: values.description,
@@ -42,8 +39,14 @@ export async function main() {
42
39
  clientType: 'codex',
43
40
  baseUrl: values['base-url'],
44
41
  requestedAgentId: existingAgentId,
42
+ localRegistrationId: pending.localRegistrationId,
45
43
  }, {
46
- onSubmitted: (requestId) => {
44
+ onSubmitted: (requestId, pollToken) => {
45
+ updatePendingRegistration(profileName, {
46
+ requestId,
47
+ pollToken,
48
+ clientType: 'codex',
49
+ });
47
50
  console.log(`Registration submitted (request ID: ${requestId}).`);
48
51
  console.log('Waiting for approval in Canon app...');
49
52
  },
@@ -54,24 +57,26 @@ export async function main() {
54
57
  console.log('');
55
58
  switch (result.status) {
56
59
  case 'approved': {
57
- mkdirSync(CANON_DIR, { recursive: true });
58
- let profiles = {};
59
- try {
60
- profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
60
+ if (!result.apiKey || !result.agentId || !result.agentName) {
61
+ console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
62
+ process.exit(1);
61
63
  }
62
- catch {
63
- // File does not exist yet.
64
- }
65
- profiles[profileName] = {
64
+ upsertAgentProfile(profileName, {
66
65
  apiKey: result.apiKey,
67
66
  agentId: result.agentId,
68
67
  agentName: result.agentName,
69
68
  registeredAt: new Date().toISOString(),
70
- };
71
- writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
69
+ clientType: 'codex',
70
+ ...(typeof values['base-url'] === 'string' ? { baseUrl: values['base-url'] } : {}),
71
+ });
72
+ if (result.requestId) {
73
+ await ackRegistrationApproval(values['base-url'], result.requestId, result.pollToken);
74
+ }
75
+ clearPendingRegistration(profileName);
72
76
  console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
73
77
  console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
74
- console.log('Start it with: canon-codex --cwd /path/to/project');
78
+ console.log('Start it with: CANON_AGENT=' + profileName + ' canon-codex --cwd /path/to/project');
79
+ console.log('Keep that terminal open. Closing it takes this local agent offline.');
75
80
  break;
76
81
  }
77
82
  case 'rejected':
@@ -1,3 +1,4 @@
1
- export declare function loadStoredThreadId(agentId: string, conversationId: string, cwd: string): string | null;
2
- export declare function saveStoredThreadId(agentId: string, conversationId: string, cwd: string, threadId: string): void;
3
- export declare function clearStoredThreadId(agentId: string, conversationId: string): void;
1
+ import { type ExecutionEnvironmentMode } from '@canonmsg/core';
2
+ export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode): string | null;
3
+ export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode): void;
4
+ export declare function clearStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { CANON_DIR } from '@canonmsg/core';
3
+ import { CANON_DIR, clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
4
4
  const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
5
5
  function loadStore() {
6
6
  try {
@@ -14,24 +14,50 @@ function saveStore(store) {
14
14
  mkdirSync(CANON_DIR, { recursive: true });
15
15
  writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
16
16
  }
17
- export function loadStoredThreadId(agentId, conversationId, cwd) {
17
+ export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
18
+ if (runtimeId) {
19
+ const state = loadRuntimeSessionState(runtimeId, {
20
+ conversationId,
21
+ baseCwd,
22
+ executionMode,
23
+ });
24
+ if (state?.threadId)
25
+ return state.threadId;
26
+ }
18
27
  const store = loadStore();
19
28
  const record = store.agents[agentId]?.[conversationId];
20
- if (!record || record.cwd !== cwd)
29
+ if (!record || record.cwd !== baseCwd)
21
30
  return null;
22
31
  return record.threadId;
23
32
  }
24
- export function saveStoredThreadId(agentId, conversationId, cwd, threadId) {
33
+ export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode) {
34
+ if (runtimeId) {
35
+ saveRuntimeSessionState(runtimeId, {
36
+ conversationId,
37
+ baseCwd,
38
+ executionMode,
39
+ threadId,
40
+ });
41
+ return;
42
+ }
25
43
  const store = loadStore();
26
44
  store.agents[agentId] ??= {};
27
45
  store.agents[agentId][conversationId] = {
28
46
  threadId,
29
- cwd,
47
+ cwd: baseCwd,
30
48
  updatedAt: new Date().toISOString(),
31
49
  };
32
50
  saveStore(store);
33
51
  }
34
- export function clearStoredThreadId(agentId, conversationId) {
52
+ export function clearStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
53
+ if (runtimeId) {
54
+ clearRuntimeSessionState(runtimeId, {
55
+ conversationId,
56
+ baseCwd,
57
+ executionMode,
58
+ });
59
+ return;
60
+ }
35
61
  const store = loadStore();
36
62
  if (!store.agents[agentId]?.[conversationId])
37
63
  return;
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.1",
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.10.1",
33
+ "@canonmsg/core": "^0.13.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"