@canonmsg/codex-plugin 0.11.12 → 0.12.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.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { dirname } from 'node:path';
5
6
  import { parseArgs } from 'node:util';
6
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
7
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
10
  import { CodexConversationAdapter, } from './adapter.js';
11
+ import { CodexAppServerAdapter } from './app-server-adapter.js';
12
+ import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
10
13
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
11
14
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
12
15
  import { detectCodexCliVersion } from './codex-cli-version.js';
@@ -64,6 +67,19 @@ let workspaceRoots = [];
64
67
  let workspaceRootMetadata = [];
65
68
  function buildCodexRuntimeDescriptor(input) {
66
69
  const commands = [
70
+ ...(input.supportsPlanMode
71
+ ? [{
72
+ id: 'plan',
73
+ label: 'Plan first',
74
+ description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
75
+ aliases: ['plan'],
76
+ category: 'plan',
77
+ placements: ['composer_slash', 'command_palette'],
78
+ availability: ['always'],
79
+ trailingTextBehavior: 'send_as_prompt',
80
+ dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
81
+ }]
82
+ : []),
67
83
  {
68
84
  id: 'runtime-status',
69
85
  label: 'Runtime status',
@@ -79,6 +95,8 @@ function buildCodexRuntimeDescriptor(input) {
79
95
  ...RUNTIME_NEW_SESSION_ACTION,
80
96
  primitive: 'session.new',
81
97
  },
98
+ RUNTIME_STOP_ACTION,
99
+ RUNTIME_STOP_AND_DROP_ACTION,
82
100
  ];
83
101
  const descriptor = buildFirstPartyCodingRuntimeDescriptor({
84
102
  clientType: 'codex',
@@ -91,11 +109,6 @@ function buildCodexRuntimeDescriptor(input) {
91
109
  presentation: input.presentation,
92
110
  streamingTextMode: 'snapshot',
93
111
  commands,
94
- actions: [
95
- RUNTIME_STOP_ACTION,
96
- RUNTIME_STOP_AND_DROP_ACTION,
97
- RUNTIME_NEW_SESSION_ACTION,
98
- ],
99
112
  });
100
113
  if (input.models.length > 0) {
101
114
  return descriptor;
@@ -119,18 +132,6 @@ function buildCodexModelOptions(model) {
119
132
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
120
133
  : [];
121
134
  }
122
- function normalizeRuntimeTurnState(value) {
123
- const normalizedTurn = normalizeTurnState(value);
124
- if (normalizedTurn) {
125
- return {
126
- state: normalizedTurn.state,
127
- ...(normalizedTurn.openedAt !== undefined ? { openedAt: normalizedTurn.openedAt } : {}),
128
- ...(normalizedTurn.updatedAt !== undefined ? { updatedAt: normalizedTurn.updatedAt } : {}),
129
- ...(normalizedTurn.turnUpdatedAt !== undefined ? { turnUpdatedAt: normalizedTurn.turnUpdatedAt } : {}),
130
- };
131
- }
132
- return null;
133
- }
134
135
  async function publishAgentRuntime(agentId, runtime) {
135
136
  await publishHostAgentRuntime(agentId, 'codex', runtime);
136
137
  }
@@ -249,6 +250,78 @@ function stringArgs(value) {
249
250
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
250
251
  : undefined;
251
252
  }
253
+ function supportsCodexAppServer(codexBin) {
254
+ if (process.env.CANON_CODEX_TRANSPORT === 'exec')
255
+ return false;
256
+ if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
257
+ return true;
258
+ const result = spawnSync(codexBin, ['app-server', '--help'], {
259
+ encoding: 'utf8',
260
+ stdio: ['ignore', 'ignore', 'ignore'],
261
+ });
262
+ return result.status === 0;
263
+ }
264
+ function parsePlanCommand(content) {
265
+ const trimmed = content.trimStart();
266
+ if (!trimmed.startsWith('/plan'))
267
+ return { planMode: false, content };
268
+ const rest = trimmed.replace(/^\/plan(?:\s+)?/i, '').trim();
269
+ return {
270
+ planMode: true,
271
+ content: rest || 'Please inspect the request and propose a plan before making changes.',
272
+ };
273
+ }
274
+ function mapCodexQuestions(value) {
275
+ if (!Array.isArray(value))
276
+ return undefined;
277
+ const questions = value.slice(0, 12).flatMap((entry) => {
278
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
279
+ return [];
280
+ const record = entry;
281
+ const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 120) : null;
282
+ const question = typeof record.question === 'string' && record.question.trim()
283
+ ? record.question.trim().slice(0, 1000)
284
+ : null;
285
+ if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
286
+ return [];
287
+ const header = typeof record.header === 'string' && record.header.trim()
288
+ ? record.header.trim().slice(0, 120)
289
+ : undefined;
290
+ const rawOptions = Array.isArray(record.options) ? record.options : [];
291
+ const choices = rawOptions.slice(0, 12).flatMap((option) => {
292
+ if (!option || typeof option !== 'object' || Array.isArray(option))
293
+ return [];
294
+ const optionRecord = option;
295
+ const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
296
+ ? optionRecord.label.trim().slice(0, 120)
297
+ : null;
298
+ if (!label)
299
+ return [];
300
+ const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
301
+ ? optionRecord.description.trim().slice(0, 300)
302
+ : undefined;
303
+ return [{ label, value: label, ...(description ? { description } : {}) }];
304
+ });
305
+ return [{
306
+ id,
307
+ question,
308
+ ...(header ? { header } : {}),
309
+ ...(choices.length > 0 ? { choices } : {}),
310
+ ...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
311
+ ...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
312
+ ...(record.isSecret === true ? { isSecret: true } : {}),
313
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
314
+ }];
315
+ });
316
+ return questions.length > 0 ? questions : undefined;
317
+ }
318
+ function isRecord(value) {
319
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
320
+ }
321
+ function readString(record, key) {
322
+ const value = record[key];
323
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
324
+ }
252
325
  export async function main() {
253
326
  setDefaultResultOrder('ipv4first');
254
327
  const { values: args } = parseArgs({
@@ -290,12 +363,14 @@ export async function main() {
290
363
  }
291
364
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
292
365
  const codexCliStatus = detectCodexCliVersion(codexBin);
366
+ const useAppServer = supportsCodexAppServer(codexBin);
293
367
  if (codexCliStatus.version) {
294
368
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
295
369
  }
296
370
  else {
297
371
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
298
372
  }
373
+ console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
299
374
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
300
375
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
301
376
  const client = new CanonClient(apiKey, baseUrl);
@@ -418,14 +493,6 @@ export async function main() {
418
493
  pendingMembershipChanges.delete(conversationId);
419
494
  }
420
495
  }
421
- async function loadSenderRuntimeState(conversationId, senderId) {
422
- try {
423
- return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
424
- }
425
- catch {
426
- return null;
427
- }
428
- }
429
496
  async function loadHydratedInboundContext(input) {
430
497
  const [conversation, page] = await Promise.all([
431
498
  getConversationMeta(input.conversationId),
@@ -523,6 +590,61 @@ export async function main() {
523
590
  function clearStreaming(conversationId) {
524
591
  runtimeState.clearStreaming(conversationId).catch(() => { });
525
592
  }
593
+ function writeCodexStreaming(session, text, status) {
594
+ if (text !== null) {
595
+ session.turnLiveText = text;
596
+ }
597
+ else if (status !== 'thinking' && session.turnLiveText === 'Thinking…') {
598
+ session.turnLiveText = '';
599
+ }
600
+ runtimeState.writeStreaming(session.conversationId, {
601
+ text: session.turnLiveText,
602
+ status,
603
+ messageId: session.currentTurnId ?? undefined,
604
+ turnId: session.currentTurnId,
605
+ blocks: session.turnBlocks,
606
+ }).catch(() => { });
607
+ }
608
+ function upsertTurnBlock(session, block) {
609
+ const now = Date.now();
610
+ const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
611
+ const existing = index >= 0 ? session.turnBlocks[index] : null;
612
+ const next = {
613
+ ...(existing ?? {
614
+ sequence: session.turnBlocks.length + 1,
615
+ createdAt: now,
616
+ }),
617
+ ...block,
618
+ turnId: session.currentTurnId ?? block.id,
619
+ updatedAt: now,
620
+ };
621
+ session.turnBlocks = index >= 0
622
+ ? [
623
+ ...session.turnBlocks.slice(0, index),
624
+ next,
625
+ ...session.turnBlocks.slice(index + 1),
626
+ ]
627
+ : [...session.turnBlocks, next];
628
+ }
629
+ function completeTurnBlock(session, id, summary) {
630
+ const existing = session.turnBlocks.find((block) => block.id === id);
631
+ if (!existing)
632
+ return;
633
+ upsertTurnBlock(session, {
634
+ id,
635
+ kind: existing.kind,
636
+ status: 'completed',
637
+ title: existing.title,
638
+ text: existing.text,
639
+ summary: summary ?? existing.summary,
640
+ });
641
+ }
642
+ function buildFinalTurnTrail(session) {
643
+ return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
644
+ ...block,
645
+ turnId: session.currentTurnId ?? block.turnId,
646
+ })));
647
+ }
526
648
  async function handoffFinalMessage(conversationId) {
527
649
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
528
650
  clearStreaming(conversationId);
@@ -555,6 +677,9 @@ export async function main() {
555
677
  return;
556
678
  session.closed = true;
557
679
  stopVisibleWorkSignal(session);
680
+ if ('close' in session.adapter && typeof session.adapter.close === 'function') {
681
+ session.adapter.close();
682
+ }
558
683
  releaseConversationEnvironment(session.environment);
559
684
  clearStreaming(conversationId);
560
685
  runtimeState.clearSessionState(conversationId).catch(() => { });
@@ -638,11 +763,20 @@ export async function main() {
638
763
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
639
764
  }
640
765
  const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
641
- const session = {
642
- conversationId,
643
- cwd: sessionCwd,
644
- environment,
645
- adapter: new CodexConversationAdapter({
766
+ const adapter = useAppServer
767
+ ? new CodexAppServerAdapter({
768
+ cwd: sessionCwd,
769
+ threadId: storedThreadId,
770
+ codexBin,
771
+ model: policy.model ?? null,
772
+ sandbox: policy.sandbox,
773
+ approvalPolicy: policy.approvalPolicy,
774
+ addDirs: args['add-dir'] ?? [],
775
+ configOverrides: args.config ?? [],
776
+ fullAuto: policy.fullAuto,
777
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
778
+ })
779
+ : new CodexConversationAdapter({
646
780
  cwd: sessionCwd,
647
781
  threadId: storedThreadId,
648
782
  codexBin,
@@ -654,7 +788,12 @@ export async function main() {
654
788
  configOverrides: args.config ?? [],
655
789
  fullAuto: policy.fullAuto,
656
790
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
657
- }),
791
+ });
792
+ const session = {
793
+ conversationId,
794
+ cwd: sessionCwd,
795
+ environment,
796
+ adapter,
658
797
  queue: [],
659
798
  running: false,
660
799
  state: {
@@ -673,6 +812,8 @@ export async function main() {
673
812
  lastActivity: Date.now(),
674
813
  typingKeepaliveTimer: null,
675
814
  closed: false,
815
+ turnLiveText: '',
816
+ turnBlocks: [],
676
817
  };
677
818
  sessions.set(conversationId, session);
678
819
  await Promise.all([
@@ -697,8 +838,8 @@ export async function main() {
697
838
  pendingSessionCreations.delete(conversationId);
698
839
  }
699
840
  }
700
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = []) {
701
- const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs };
841
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
842
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
702
843
  if (toFront) {
703
844
  session.queue.unshift(nextPrompt);
704
845
  }
@@ -709,8 +850,145 @@ export async function main() {
709
850
  writeTurn(session);
710
851
  void runNextTurn(session);
711
852
  }
853
+ async function waitForRuntimeInputResponse(input) {
854
+ while (Date.now() < input.expiresAt) {
855
+ const response = await client.consumeRuntimeInputResponse({
856
+ conversationId: input.conversationId,
857
+ inputId: input.inputId,
858
+ }).catch(() => null);
859
+ if (response?.status === 'submitted') {
860
+ return { status: 'submitted', value: response.value, answers: response.answers };
861
+ }
862
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
863
+ return { status: response.status };
864
+ }
865
+ await sleep(1_000);
866
+ }
867
+ const response = await client.consumeRuntimeInputResponse({
868
+ conversationId: input.conversationId,
869
+ inputId: input.inputId,
870
+ }).catch(() => null);
871
+ if (response?.status === 'submitted') {
872
+ return { status: 'submitted', value: response.value, answers: response.answers };
873
+ }
874
+ return { status: 'timeout' };
875
+ }
876
+ async function waitForRuntimeApprovalResponse(input) {
877
+ while (Date.now() < input.expiresAt) {
878
+ const response = await client.consumeRuntimeApprovalResponse({
879
+ conversationId: input.conversationId,
880
+ approvalId: input.approvalId,
881
+ }).catch(() => null);
882
+ if (response?.status === 'allow') {
883
+ return { decision: 'allow', sessionRule: response.sessionRule };
884
+ }
885
+ if (response?.status === 'deny' || response?.status === 'timeout') {
886
+ return { decision: 'deny' };
887
+ }
888
+ await sleep(1_000);
889
+ }
890
+ await client.consumeRuntimeApprovalResponse({
891
+ conversationId: input.conversationId,
892
+ approvalId: input.approvalId,
893
+ }).catch(() => null);
894
+ return { decision: 'deny' };
895
+ }
896
+ async function handleCodexServerRequest(session, request) {
897
+ const requestId = String(request.id);
898
+ const params = request.params;
899
+ const expiresAt = Date.now() + 30 * 60_000;
900
+ if (request.method === 'item/tool/requestUserInput') {
901
+ const paramsInput = isRecord(params.input) ? params.input : null;
902
+ const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
903
+ const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
904
+ const inputId = readString(params, 'itemId') ?? requestId;
905
+ await client.createRuntimeInputRequest({
906
+ conversationId: session.conversationId,
907
+ inputId,
908
+ kind: 'clarify',
909
+ expiresAt,
910
+ responseUserId: ownerId ?? undefined,
911
+ title: 'Codex needs input',
912
+ prompt: questions?.length
913
+ ? 'Codex needs your input to continue.'
914
+ : 'Codex needs input.',
915
+ ...(questions ? { questions } : {}),
916
+ sensitive: Boolean(questions?.some((question) => question.isSecret)),
917
+ native: {
918
+ runtime: 'codex',
919
+ method: request.method,
920
+ requestId,
921
+ turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
922
+ handles: {
923
+ itemId: readString(params, 'itemId') ?? '',
924
+ threadId: readString(params, 'threadId') ?? '',
925
+ },
926
+ },
927
+ turnId: session.currentTurnId ?? undefined,
928
+ });
929
+ const response = await waitForRuntimeInputResponse({
930
+ conversationId: session.conversationId,
931
+ inputId,
932
+ expiresAt,
933
+ });
934
+ return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
935
+ }
936
+ const mappedApproval = mapCodexAppServerApprovalRequest({
937
+ method: request.method,
938
+ params,
939
+ });
940
+ if (mappedApproval) {
941
+ const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
942
+ await client.createRuntimeApprovalRequest({
943
+ conversationId: session.conversationId,
944
+ approvalId,
945
+ toolName: mappedApproval.toolName,
946
+ toolSummary: mappedApproval.toolSummary,
947
+ category: mappedApproval.category,
948
+ risk: mappedApproval.risk,
949
+ riskLevel: mappedApproval.riskLevel,
950
+ native: {
951
+ ...mappedApproval.native,
952
+ requestId,
953
+ method: request.method,
954
+ },
955
+ details: mappedApproval.details,
956
+ responseUserId: ownerId ?? undefined,
957
+ allowSessionRule: true,
958
+ expiresAt,
959
+ turnId: session.currentTurnId ?? undefined,
960
+ });
961
+ const response = await waitForRuntimeApprovalResponse({
962
+ conversationId: session.conversationId,
963
+ approvalId,
964
+ expiresAt,
965
+ });
966
+ if (request.method === 'item/permissions/requestApproval') {
967
+ return response.decision === 'allow'
968
+ ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
969
+ : { permissions: {}, scope: 'turn' };
970
+ }
971
+ return mapCanonApprovalResultToCodexDecision({
972
+ decision: response.decision,
973
+ ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
974
+ });
975
+ }
976
+ return {};
977
+ }
712
978
  async function enqueueInboundMessage(input) {
713
979
  knownConversationIds.add(input.conversationId);
980
+ if (isRecord(input.message.metadata)
981
+ && input.message.metadata.type === 'plan_approval_reply'
982
+ && typeof input.message.metadata.decision === 'string') {
983
+ const session = await getOrCreateSession(input.conversationId);
984
+ const feedback = readString(input.message.metadata, 'feedback');
985
+ const decision = input.message.metadata.decision;
986
+ const prompt = decision === 'approve'
987
+ ? 'The plan was approved. Implement the approved plan now.'
988
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
989
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
990
+ return;
991
+ }
714
992
  let materialized = [];
715
993
  if (input.message.id) {
716
994
  try {
@@ -723,7 +1001,11 @@ export async function main() {
723
1001
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
724
1002
  }
725
1003
  }
726
- const content = renderInboundContent(input.message, materialized);
1004
+ const renderedContent = renderInboundContent(input.message, materialized);
1005
+ const planCommand = useAppServer
1006
+ ? parsePlanCommand(renderedContent)
1007
+ : { planMode: false, content: renderedContent };
1008
+ const content = planCommand.content;
727
1009
  const hydrated = await loadHydratedInboundContext({
728
1010
  conversationId: input.conversationId,
729
1011
  message: input.message,
@@ -773,7 +1055,6 @@ export async function main() {
773
1055
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
774
1056
  metadata: {
775
1057
  turnSemantics: 'turn_complete',
776
- turnComplete: true,
777
1058
  replyBehavior: 'suppress_auto_reply',
778
1059
  },
779
1060
  }).catch(() => { });
@@ -789,14 +1070,14 @@ export async function main() {
789
1070
  replyContext,
790
1071
  });
791
1072
  if (session.running && deliveryIntent === 'interrupt') {
792
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1073
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
793
1074
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
794
1075
  await session.adapter.interrupt().catch(() => { });
795
1076
  clearStreaming(input.conversationId);
796
1077
  client.setTyping(input.conversationId, false).catch(() => { });
797
1078
  return;
798
1079
  }
799
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1080
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
800
1081
  }
801
1082
  async function runNextTurn(session) {
802
1083
  if (session.running || session.closed)
@@ -808,6 +1089,8 @@ export async function main() {
808
1089
  session.state.lastError = undefined;
809
1090
  session.state.state = 'running';
810
1091
  session.currentTurnId = randomUUID();
1092
+ session.turnLiveText = '';
1093
+ session.turnBlocks = [];
811
1094
  session.currentTurnOpenedAt = Date.now();
812
1095
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
813
1096
  session.lastAcceptedIntent = nextTurn.intent;
@@ -817,12 +1100,7 @@ export async function main() {
817
1100
  writeState(session);
818
1101
  writeTurn(session);
819
1102
  startVisibleWorkSignal(session);
820
- runtimeState.writeStreaming(session.conversationId, {
821
- text: 'Thinking…',
822
- status: 'thinking',
823
- messageId: session.currentTurnId ?? undefined,
824
- turnId: session.currentTurnId,
825
- }).catch(() => { });
1103
+ writeCodexStreaming(session, 'Thinking…', 'thinking');
826
1104
  try {
827
1105
  const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
828
1106
  if (modelGuard) {
@@ -846,12 +1124,31 @@ export async function main() {
846
1124
  writeTurn(session);
847
1125
  stopVisibleWorkSignal(session);
848
1126
  client.setTyping(session.conversationId, false).catch(() => { });
849
- runtimeState.writeStreaming(session.conversationId, {
1127
+ writeCodexStreaming(session, event.text, 'streaming');
1128
+ return;
1129
+ }
1130
+ if (event.type === 'plan.updated') {
1131
+ session.turnState = 'streaming';
1132
+ markTurnProgress(session);
1133
+ writeTurn(session);
1134
+ stopVisibleWorkSignal(session);
1135
+ client.setTyping(session.conversationId, false).catch(() => { });
1136
+ upsertTurnBlock(session, {
1137
+ id: `plan:${session.currentTurnId}`,
1138
+ kind: 'plan',
1139
+ status: 'running',
1140
+ title: 'Plan',
850
1141
  text: event.text,
851
- status: 'streaming',
852
- messageId: session.currentTurnId ?? undefined,
853
- turnId: session.currentTurnId,
854
- }).catch(() => { });
1142
+ });
1143
+ writeCodexStreaming(session, event.text, 'streaming');
1144
+ return;
1145
+ }
1146
+ if (event.type === 'waiting') {
1147
+ session.turnState = 'waiting_input';
1148
+ markTurnProgress(session);
1149
+ writeTurn(session);
1150
+ stopVisibleWorkSignal(session);
1151
+ writeCodexStreaming(session, null, 'waiting_input');
855
1152
  return;
856
1153
  }
857
1154
  if (event.type === 'command.started') {
@@ -859,20 +1156,24 @@ export async function main() {
859
1156
  markTurnProgress(session);
860
1157
  writeTurn(session);
861
1158
  startVisibleWorkSignal(session);
862
- runtimeState.writeStreaming(session.conversationId, {
863
- text: summarizeCommand(event.command),
864
- status: 'tool',
865
- messageId: session.currentTurnId ?? undefined,
866
- turnId: session.currentTurnId,
867
- }).catch(() => { });
1159
+ upsertTurnBlock(session, {
1160
+ id: `command:${session.currentTurnId}`,
1161
+ kind: 'tool',
1162
+ status: 'running',
1163
+ title: summarizeCommand(event.command),
1164
+ summary: 'Command running',
1165
+ });
1166
+ writeCodexStreaming(session, null, 'tool');
868
1167
  return;
869
1168
  }
870
1169
  if (event.type === 'command.completed') {
1170
+ completeTurnBlock(session, `command:${session.currentTurnId}`, 'Command completed');
871
1171
  if (session.turnState === 'tool') {
872
1172
  session.turnState = 'thinking';
873
1173
  markTurnProgress(session);
874
1174
  writeTurn(session);
875
1175
  startVisibleWorkSignal(session);
1176
+ writeCodexStreaming(session, null, 'thinking');
876
1177
  }
877
1178
  return;
878
1179
  }
@@ -887,7 +1188,10 @@ export async function main() {
887
1188
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
888
1189
  session.adapter.clearThreadId();
889
1190
  };
890
- const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs);
1191
+ const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1192
+ planMode: nextTurn.planMode,
1193
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1194
+ });
891
1195
  let result = await runTurnOnce();
892
1196
  if (!result.interrupted
893
1197
  && !result.finalMessage
@@ -901,10 +1205,28 @@ export async function main() {
901
1205
  if (result.threadId && !session.resetRequested) {
902
1206
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
903
1207
  }
904
- if (!result.interrupted && result.finalMessage) {
1208
+ if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1209
+ const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1210
+ responseUserId: ownerId ?? undefined,
1211
+ title: 'Codex Plan',
1212
+ body: result.finalMessage,
1213
+ });
1214
+ await client.sendMessage(session.conversationId, planApproval.text, {
1215
+ metadata: {
1216
+ ...planApproval.metadata,
1217
+ turnId: session.currentTurnId,
1218
+ turnSemantics: 'control',
1219
+ replyBehavior: 'suppress_auto_reply',
1220
+ },
1221
+ });
1222
+ await handoffFinalMessage(session.conversationId);
1223
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1224
+ }
1225
+ else if (!result.interrupted && result.finalMessage) {
905
1226
  if (isRecoverableCodexThreadError(result.errorText)) {
906
1227
  clearStoredThread();
907
1228
  }
1229
+ const turnTrail = buildFinalTurnTrail(session);
908
1230
  await client.sendMessage(session.conversationId, result.finalMessage, {
909
1231
  ...(session.activeSelfContextId
910
1232
  ? { selfContextId: session.activeSelfContextId }
@@ -912,8 +1234,8 @@ export async function main() {
912
1234
  metadata: {
913
1235
  turnId: session.currentTurnId,
914
1236
  turnSemantics: 'turn_complete',
915
- turnComplete: true,
916
1237
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1238
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
917
1239
  },
918
1240
  });
919
1241
  await handoffFinalMessage(session.conversationId);
@@ -926,6 +1248,7 @@ export async function main() {
926
1248
  if (result.errorText) {
927
1249
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
928
1250
  }
1251
+ const turnTrail = buildFinalTurnTrail(session);
929
1252
  await client.sendMessage(session.conversationId, userVisibleError, {
930
1253
  ...(session.activeSelfContextId
931
1254
  ? { selfContextId: session.activeSelfContextId }
@@ -933,8 +1256,8 @@ export async function main() {
933
1256
  metadata: {
934
1257
  turnId: session.currentTurnId,
935
1258
  turnSemantics: 'turn_complete',
936
- turnComplete: true,
937
1259
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1260
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
938
1261
  },
939
1262
  });
940
1263
  await handoffFinalMessage(session.conversationId);
@@ -964,7 +1287,6 @@ export async function main() {
964
1287
  metadata: {
965
1288
  turnId: session.currentTurnId,
966
1289
  turnSemantics: 'turn_complete',
967
- turnComplete: true,
968
1290
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
969
1291
  },
970
1292
  }).catch(() => { });
@@ -1024,6 +1346,7 @@ export async function main() {
1024
1346
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1025
1347
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1026
1348
  presentation: runtimePresentation,
1349
+ supportsPlanMode: useAppServer,
1027
1350
  }),
1028
1351
  };
1029
1352
  async function baselineControlSignal(conversationId) {
@@ -1108,12 +1431,14 @@ export async function main() {
1108
1431
  {
1109
1432
  id: 'transport',
1110
1433
  label: 'Transport',
1111
- value: 'exec --json',
1434
+ value: useAppServer ? 'app-server' : 'exec --json',
1112
1435
  },
1113
1436
  {
1114
1437
  id: 'streaming',
1115
1438
  label: 'Live output',
1116
- value: 'Thinking, tools, and completed-message previews',
1439
+ value: useAppServer
1440
+ ? 'Plans, questions, approvals, tools, and message deltas'
1441
+ : 'Thinking, tools, and completed-message previews',
1117
1442
  },
1118
1443
  {
1119
1444
  id: 'codex-cli',
@@ -1124,8 +1449,8 @@ export async function main() {
1124
1449
  {
1125
1450
  id: 'nativeActions',
1126
1451
  label: 'Native actions',
1127
- value: 'Limited until app-server transport',
1128
- tone: 'warning',
1452
+ value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1453
+ ...(useAppServer ? {} : { tone: 'warning' }),
1129
1454
  },
1130
1455
  ],
1131
1456
  execution: {
@@ -1139,8 +1464,9 @@ export async function main() {
1139
1464
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1140
1465
  },
1141
1466
  notes: [
1142
- '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.',
1143
- '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.',
1467
+ useAppServer
1468
+ ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1469
+ : 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not native plan questions or structured approvals.',
1144
1470
  ],
1145
1471
  };
1146
1472
  await runtimeState.writeRuntimeInfo(conversationId, payload);
@@ -1215,6 +1541,7 @@ export async function main() {
1215
1541
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1216
1542
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1217
1543
  presentation: runtimePresentation,
1544
+ supportsPlanMode: useAppServer,
1218
1545
  }),
1219
1546
  };
1220
1547
  }
@@ -1235,6 +1562,7 @@ export async function main() {
1235
1562
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1236
1563
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1237
1564
  presentation: runtimePresentation,
1565
+ supportsPlanMode: useAppServer,
1238
1566
  }),
1239
1567
  };
1240
1568
  }
@@ -1264,13 +1592,9 @@ export async function main() {
1264
1592
  ? inboundMessages.slice(cursorIndex + 1)
1265
1593
  : inboundMessages.slice(-1);
1266
1594
  for (const latestMessage of messagesToRecover) {
1267
- const senderTurnState = latestMessage.senderType === 'ai_agent'
1268
- ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
1269
- : null;
1270
1595
  const triggerDecision = shouldTriggerAgentTurn({
1271
1596
  senderType: latestMessage.senderType ?? 'human',
1272
1597
  metadata: latestMessage.metadata,
1273
- senderTurnState,
1274
1598
  });
1275
1599
  if (!triggerDecision.allow) {
1276
1600
  console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);