@canonmsg/codex-plugin 0.19.0 → 0.19.2

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/adapter.d.ts CHANGED
@@ -8,6 +8,7 @@ export type CodexEvent = {
8
8
  } | {
9
9
  type: 'message';
10
10
  text: string;
11
+ delta?: string;
11
12
  itemId?: string;
12
13
  } | {
13
14
  type: 'plan.updated';
@@ -39,6 +39,7 @@ export declare class CodexAppServerAdapter {
39
39
  private skillsCache;
40
40
  private messageTextByItem;
41
41
  private planText;
42
+ private traceTurnEpochMs;
42
43
  constructor(opts: {
43
44
  cwd: string;
44
45
  threadId?: string | null;
@@ -84,9 +85,18 @@ export declare class CodexAppServerAdapter {
84
85
  private handleLine;
85
86
  private handleServerRequest;
86
87
  private handleNotification;
88
+ /**
89
+ * The turn's final message is the JOIN of every agentMessage item, in
90
+ * insertion order — not just the last item. The streamed turn trail folds
91
+ * all items (one text segment per item), so the final text must match the
92
+ * streamed/folded text or the app renders the turn twice.
93
+ */
94
+ private joinedAgentMessageText;
87
95
  private isCurrentThreadNotification;
88
96
  private resolveCurrentTurn;
89
97
  private clearActiveTurn;
90
98
  private sendRequest;
91
99
  private write;
100
+ private trace;
101
+ private traceLine;
92
102
  }
@@ -31,6 +31,7 @@ export class CodexAppServerAdapter {
31
31
  skillsCache = null;
32
32
  messageTextByItem = new Map();
33
33
  planText = '';
34
+ traceTurnEpochMs = null;
34
35
  constructor(opts) {
35
36
  this.cwd = opts.cwd;
36
37
  this.threadId = opts.threadId ?? null;
@@ -117,6 +118,8 @@ export class CodexAppServerAdapter {
117
118
  this.interrupted = false;
118
119
  this.messageTextByItem.clear();
119
120
  this.planText = '';
121
+ this.traceTurnEpochMs = Date.now();
122
+ this.trace('turn/run begin');
120
123
  try {
121
124
  if (this.threadId && this.loadedThreadId !== this.threadId) {
122
125
  const resumed = await this.sendRequest('thread/resume', {
@@ -158,9 +161,11 @@ export class CodexAppServerAdapter {
158
161
  this.currentTurnReject = reject;
159
162
  });
160
163
  turnPromise.catch(() => { });
164
+ const turnInput = await this.buildTurnInput(prompt, imagePaths);
165
+ this.trace('turn/start sent');
161
166
  const turnStarted = await this.sendRequest('turn/start', {
162
167
  threadId: this.threadId,
163
- input: await this.buildTurnInput(prompt, imagePaths),
168
+ input: turnInput,
164
169
  ...(this.model ? { model: this.model } : {}),
165
170
  ...this.sandboxPolicyPayload(_extraAddDirs),
166
171
  collaborationMode: {
@@ -172,6 +177,7 @@ export class CodexAppServerAdapter {
172
177
  },
173
178
  },
174
179
  });
180
+ this.trace('turn/start ack');
175
181
  const turn = turnStarted.turn;
176
182
  if (this.currentTurnResolve) {
177
183
  this.currentTurnId = readString(turn, 'id') ?? null;
@@ -322,6 +328,7 @@ export class CodexAppServerAdapter {
322
328
  const message = parseJson(line);
323
329
  if (!message)
324
330
  return;
331
+ this.traceLine(line, message);
325
332
  if ('id' in message && ('result' in message || 'error' in message) && !('method' in message)) {
326
333
  const id = Number(message.id);
327
334
  const pending = this.pending.get(id);
@@ -393,9 +400,9 @@ export class CodexAppServerAdapter {
393
400
  const delta = readRawString(params, 'delta') ?? '';
394
401
  const next = `${this.messageTextByItem.get(itemId) ?? ''}${delta}`;
395
402
  this.messageTextByItem.set(itemId, next);
396
- this.currentFinalMessage = next.trim() ? next : this.currentFinalMessage;
403
+ this.currentFinalMessage = this.joinedAgentMessageText() ?? this.currentFinalMessage;
397
404
  if (next.trim())
398
- this.currentOnEvent?.({ type: 'message', text: next, itemId });
405
+ this.currentOnEvent?.({ type: 'message', text: next, delta, itemId });
399
406
  return;
400
407
  }
401
408
  if (method === 'turn/plan/updated') {
@@ -432,7 +439,8 @@ export class CodexAppServerAdapter {
432
439
  const itemId = readString(item, 'id');
433
440
  const text = readString(item, 'text');
434
441
  if (text) {
435
- this.currentFinalMessage = text;
442
+ this.messageTextByItem.set(itemId ?? 'agent-message', text);
443
+ this.currentFinalMessage = this.joinedAgentMessageText() ?? text;
436
444
  this.currentOnEvent?.({
437
445
  type: 'message',
438
446
  text,
@@ -466,6 +474,7 @@ export class CodexAppServerAdapter {
466
474
  return;
467
475
  }
468
476
  if (method === 'turn/completed') {
477
+ this.trace('turn/completed notification');
469
478
  const turn = params.turn;
470
479
  const status = turn?.status;
471
480
  if (isRecord(status) && status.type === 'failed') {
@@ -486,6 +495,18 @@ export class CodexAppServerAdapter {
486
495
  this.currentErrorText = stringifyPreview(params);
487
496
  }
488
497
  }
498
+ /**
499
+ * The turn's final message is the JOIN of every agentMessage item, in
500
+ * insertion order — not just the last item. The streamed turn trail folds
501
+ * all items (one text segment per item), so the final text must match the
502
+ * streamed/folded text or the app renders the turn twice.
503
+ */
504
+ joinedAgentMessageText() {
505
+ const joined = [...this.messageTextByItem.values()]
506
+ .filter((text) => text.trim())
507
+ .join('\n\n');
508
+ return joined.trim() ? joined : null;
509
+ }
489
510
  isCurrentThreadNotification(params) {
490
511
  const threadId = readNotificationThreadId(params);
491
512
  if (threadId && this.threadId && threadId !== this.threadId)
@@ -517,6 +538,7 @@ export class CodexAppServerAdapter {
517
538
  this.currentErrorText = null;
518
539
  this.messageTextByItem.clear();
519
540
  this.planText = '';
541
+ this.traceTurnEpochMs = null;
520
542
  }
521
543
  sendRequest(method, params) {
522
544
  const id = this.requestSeq++;
@@ -536,6 +558,33 @@ export class CodexAppServerAdapter {
536
558
  throw new Error('Codex app-server is not running');
537
559
  this.child.stdin.write(`${JSON.stringify(message)}\n`);
538
560
  }
561
+ trace(message) {
562
+ if (!isCodexTraceEnabled())
563
+ return;
564
+ const elapsedMs = this.traceTurnEpochMs === null ? 0 : Date.now() - this.traceTurnEpochMs;
565
+ console.error(`[canon-codex-trace] +${elapsedMs}ms ${message}`);
566
+ }
567
+ traceLine(line, message) {
568
+ if (!isCodexTraceEnabled())
569
+ return;
570
+ const method = typeof message.method === 'string' ? message.method : null;
571
+ if (!method) {
572
+ const id = 'id' in message ? ` id=${String(message.id)}` : '';
573
+ this.trace(`line bytes=${line.length} response${id}`);
574
+ return;
575
+ }
576
+ if (method === 'item/agentMessage/delta') {
577
+ const params = isRecord(message.params) ? message.params : {};
578
+ const itemId = readString(params, 'itemId') ?? 'agent-message';
579
+ const delta = readRawString(params, 'delta') ?? '';
580
+ this.trace(`line bytes=${line.length} method=${method} itemId=${itemId} deltaLen=${delta.length}`);
581
+ return;
582
+ }
583
+ this.trace(`line bytes=${line.length} method=${method}`);
584
+ }
585
+ }
586
+ function isCodexTraceEnabled() {
587
+ return process.env.CANON_CODEX_TRACE_EVENTS === '1';
539
588
  }
540
589
  function parseJson(line) {
541
590
  try {
package/dist/host.js CHANGED
@@ -26,7 +26,7 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
26
26
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
27
27
  import { attachCodexControlNotifications } from './control-channel.js';
28
28
  import { runCli } from './cli-entry.js';
29
- import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, textSegmentBlockId, } from './turn-activity.js';
29
+ import { beginCommandBlock, applyCodexMessageToStreamingOutput, claimCommandBlock, createCommandBlockTracker, hasSpeechSegmentText, } from './turn-activity.js';
30
30
  const HELP = `canon-codex — run a local Codex agent host for Canon
31
31
 
32
32
  USAGE
@@ -108,6 +108,35 @@ const CODEX_RUNTIME_CAPABILITIES = {
108
108
  supportsQueue: true,
109
109
  supportsNonFinalPermanentMessages: false,
110
110
  };
111
+ function isCodexTraceEnabled() {
112
+ return process.env.CANON_CODEX_TRACE_EVENTS === '1';
113
+ }
114
+ function createCodexTracePort(port) {
115
+ if (!isCodexTraceEnabled())
116
+ return port;
117
+ return new Proxy(port, {
118
+ get(target, property, receiver) {
119
+ if (property !== 'publishStreaming') {
120
+ return Reflect.get(target, property, receiver);
121
+ }
122
+ return async (input) => {
123
+ const startedAt = Date.now();
124
+ const textLength = typeof input.text === 'string' ? input.text.length : 0;
125
+ console.error(`[canon-codex-trace] publishStreaming sent conversation=${input.conversationId} turn=${input.turnId ?? input.messageId ?? 'unknown'} textLen=${textLength}`);
126
+ try {
127
+ const result = await target.publishStreaming(input);
128
+ console.error(`[canon-codex-trace] publishStreaming ack conversation=${input.conversationId} ackMs=${Date.now() - startedAt}`);
129
+ return result;
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ console.error(`[canon-codex-trace] publishStreaming error conversation=${input.conversationId} ackMs=${Date.now() - startedAt} error=${message}`);
134
+ throw error;
135
+ }
136
+ };
137
+ },
138
+ });
139
+ }
111
140
  // This host process resolves and locks exactly one agent profile. The lock
112
141
  // handle returned by resolveCanonAgent is held here so the top-level runCli
113
142
  // error handler (outside main's scope) can release it on a failed start —
@@ -554,7 +583,7 @@ export async function main() {
554
583
  });
555
584
  // The BridgeClient's zod-derived result shapes mirror the core wire types
556
585
  // structurally; the port pins the strong types the host machinery uses.
557
- const port = bridge.client;
586
+ const port = createCodexTracePort(bridge.client);
558
587
  bridge.client.onProtocolError((error) => {
559
588
  console.error(`[canon-codex] Bridge protocol error: ${error.message}`);
560
589
  });
@@ -976,7 +1005,12 @@ export async function main() {
976
1005
  session.turnState = 'streaming';
977
1006
  writers.writeTurn();
978
1007
  writers.stopVisibleWorkSignal();
979
- writers.streamingOutput.replaceTextSegmentSnapshot(textSegmentBlockId(session.currentTurnId, event.itemId), event.text);
1008
+ applyCodexMessageToStreamingOutput(writers.streamingOutput, {
1009
+ turnId: session.currentTurnId,
1010
+ itemId: event.itemId,
1011
+ text: event.text,
1012
+ delta: event.delta,
1013
+ });
980
1014
  return;
981
1015
  }
982
1016
  if (event.type === 'plan.updated') {
@@ -990,7 +1024,16 @@ export async function main() {
990
1024
  title: 'Plan',
991
1025
  text: event.text,
992
1026
  });
993
- writers.streamingOutput.replaceSnapshot(event.text, 'streaming').catch(() => { });
1027
+ if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1028
+ // Speech is already flowing: publish the staged plan block
1029
+ // only. Replacing the whole snapshot with plan text would be
1030
+ // undone by the next agentMessage snapshot and make the
1031
+ // smoothed bubble snap back and forth.
1032
+ writers.streamingOutput.flush().catch(() => { });
1033
+ }
1034
+ else {
1035
+ writers.streamingOutput.replaceSnapshot(event.text, 'streaming').catch(() => { });
1036
+ }
994
1037
  return;
995
1038
  }
996
1039
  if (event.type === 'waiting') {
@@ -1001,9 +1044,6 @@ export async function main() {
1001
1044
  return;
1002
1045
  }
1003
1046
  if (event.type === 'command.started') {
1004
- session.turnState = 'tool';
1005
- writers.writeTurn();
1006
- writers.startVisibleWorkSignal();
1007
1047
  const blockId = beginCommandBlock(session.turnCommandBlocks, {
1008
1048
  turnId: session.currentTurnId,
1009
1049
  command: event.command,
@@ -1016,6 +1056,16 @@ export async function main() {
1016
1056
  title: summarizeCommand(event.command),
1017
1057
  summary: 'Command running',
1018
1058
  });
1059
+ if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1060
+ // Speech is already flowing: the staged tool block narrates
1061
+ // the command in the margin. Publish it without flapping the
1062
+ // bubble status away from 'streaming'.
1063
+ writers.streamingOutput.flush().catch(() => { });
1064
+ return;
1065
+ }
1066
+ session.turnState = 'tool';
1067
+ writers.writeTurn();
1068
+ writers.startVisibleWorkSignal();
1019
1069
  writers.streamingOutput.setStatus('tool').catch(() => { });
1020
1070
  return;
1021
1071
  }
@@ -1034,12 +1084,18 @@ export async function main() {
1034
1084
  summary: 'Command completed',
1035
1085
  });
1036
1086
  }
1037
- if (session.turnState === 'tool') {
1087
+ if (session.turnState === 'tool'
1088
+ && !hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1038
1089
  session.turnState = 'thinking';
1039
1090
  writers.writeTurn();
1040
1091
  writers.startVisibleWorkSignal();
1041
1092
  writers.streamingOutput.setStatus('thinking').catch(() => { });
1042
1093
  }
1094
+ else {
1095
+ // Speech is flowing (or the turn never left streaming):
1096
+ // publish the staged completion without a status flap.
1097
+ writers.streamingOutput.flush().catch(() => { });
1098
+ }
1043
1099
  return;
1044
1100
  }
1045
1101
  if (event.type === 'turn.completed') {
@@ -1,8 +1,17 @@
1
+ import type { TurnOutputBlock } from '@canonmsg/core/contract';
1
2
  interface RunningCommandBlock {
2
3
  command: string;
3
4
  blockId: string;
4
5
  itemId?: string;
5
6
  }
7
+ /**
8
+ * True once the streaming output already carries assistant speech (folded
9
+ * text segments). While speech is flowing the host must not flap the bubble
10
+ * status to tool/thinking or clobber the live text with plan snapshots — the
11
+ * staged margin blocks already narrate that activity, and every status/text
12
+ * swap makes the smoothed bubble snap.
13
+ */
14
+ export declare function hasSpeechSegmentText(blocks: ReadonlyArray<Pick<TurnOutputBlock, 'kind' | 'text'>>): boolean;
6
15
  export interface CommandBlockTracker {
7
16
  sequence: number;
8
17
  running: RunningCommandBlock[];
@@ -15,6 +24,16 @@ export declare function createCommandBlockTracker(): CommandBlockTracker;
15
24
  * only owns the codex id scheme.
16
25
  */
17
26
  export declare function textSegmentBlockId(turnId: string | null | undefined, itemId?: string): string;
27
+ export interface CodexTextSegmentOutput {
28
+ appendTextSegmentDelta(id: string, delta: string): void;
29
+ replaceTextSegmentSnapshot(id: string, text: string): void;
30
+ }
31
+ export declare function applyCodexMessageToStreamingOutput(output: CodexTextSegmentOutput, input: {
32
+ turnId: string | null | undefined;
33
+ itemId?: string;
34
+ text: string;
35
+ delta?: string;
36
+ }): void;
18
37
  export declare function beginCommandBlock(tracker: CommandBlockTracker, input: {
19
38
  turnId: string | null | undefined;
20
39
  command: string;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * True once the streaming output already carries assistant speech (folded
3
+ * text segments). While speech is flowing the host must not flap the bubble
4
+ * status to tool/thinking or clobber the live text with plan snapshots — the
5
+ * staged margin blocks already narrate that activity, and every status/text
6
+ * swap makes the smoothed bubble snap.
7
+ */
8
+ export function hasSpeechSegmentText(blocks) {
9
+ return blocks.some((block) => block.kind === 'text' && Boolean(block.text?.trim()));
10
+ }
1
11
  export function createCommandBlockTracker() {
2
12
  return {
3
13
  sequence: 0,
@@ -17,6 +27,14 @@ function normalizeOptionalString(value) {
17
27
  export function textSegmentBlockId(turnId, itemId) {
18
28
  return `message:${turnId ?? 'turn'}:${normalizeOptionalString(itemId) ?? 'latest'}`;
19
29
  }
30
+ export function applyCodexMessageToStreamingOutput(output, input) {
31
+ const segmentId = textSegmentBlockId(input.turnId, input.itemId);
32
+ if (input.delta !== undefined) {
33
+ output.appendTextSegmentDelta(segmentId, input.delta);
34
+ return;
35
+ }
36
+ output.replaceTextSegmentSnapshot(segmentId, input.text);
37
+ }
20
38
  function nextCommandBlockId(tracker, turnId, itemId) {
21
39
  const stableTurnId = turnId ?? 'turn';
22
40
  if (itemId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,12 +29,12 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-host": "^0.3.0",
33
- "@canonmsg/agent-sdk": "^3.4.3",
34
- "@canonmsg/backend-contracts": "^1.8.0",
35
- "@canonmsg/bridge": "^0.2.0",
36
- "@canonmsg/core": "^3.1.0",
37
- "@canonmsg/framework": "^0.2.0"
32
+ "@canonmsg/agent-host": "^0.4.0",
33
+ "@canonmsg/agent-sdk": "^3.4.4",
34
+ "@canonmsg/backend-contracts": "^1.8.1",
35
+ "@canonmsg/bridge": "^0.2.1",
36
+ "@canonmsg/core": "^3.2.0",
37
+ "@canonmsg/framework": "^0.2.1"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=18.0.0"