@canonmsg/codex-plugin 0.14.1 → 0.18.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/adapter.d.ts CHANGED
@@ -52,6 +52,7 @@ export declare class CodexConversationAdapter {
52
52
  private readonly cwd;
53
53
  private readonly codexBin;
54
54
  private model;
55
+ private reasoningEffort;
55
56
  private readonly sandbox;
56
57
  private readonly legacyApprovalPolicy;
57
58
  private readonly codexProfile;
@@ -68,6 +69,7 @@ export declare class CodexConversationAdapter {
68
69
  threadId?: string | null;
69
70
  codexBin?: string;
70
71
  model?: string | null;
72
+ reasoningEffort?: string | null;
71
73
  sandbox?: CodexSandboxMode | null;
72
74
  approvalPolicy?: CodexApprovalPolicy | null;
73
75
  codexProfile?: string | null;
@@ -79,6 +81,8 @@ export declare class CodexConversationAdapter {
79
81
  getThreadId(): string | null;
80
82
  clearThreadId(): void;
81
83
  setModel(model: string | null): void;
84
+ /** Sets GPT reasoning effort, applied on the next turn via `-c model_reasoning_effort`. */
85
+ setReasoningEffort(effort: string | null): void;
82
86
  isRunning(): boolean;
83
87
  interrupt(): Promise<void>;
84
88
  runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[], _options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
package/dist/adapter.js CHANGED
@@ -5,6 +5,7 @@ export class CodexConversationAdapter {
5
5
  cwd;
6
6
  codexBin;
7
7
  model;
8
+ reasoningEffort;
8
9
  sandbox;
9
10
  legacyApprovalPolicy;
10
11
  codexProfile;
@@ -21,6 +22,7 @@ export class CodexConversationAdapter {
21
22
  this.threadId = opts.threadId ?? null;
22
23
  this.codexBin = opts.codexBin ?? 'codex';
23
24
  this.model = opts.model ?? null;
25
+ this.reasoningEffort = opts.reasoningEffort ?? null;
24
26
  this.sandbox = opts.sandbox ?? null;
25
27
  this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
26
28
  this.codexProfile = opts.codexProfile ?? null;
@@ -38,6 +40,10 @@ export class CodexConversationAdapter {
38
40
  setModel(model) {
39
41
  this.model = model;
40
42
  }
43
+ /** Sets GPT reasoning effort, applied on the next turn via `-c model_reasoning_effort`. */
44
+ setReasoningEffort(effort) {
45
+ this.reasoningEffort = effort && effort.trim() ? effort.trim() : null;
46
+ }
41
47
  isRunning() {
42
48
  return this.child !== null;
43
49
  }
@@ -177,6 +183,9 @@ export class CodexConversationAdapter {
177
183
  for (const configOverride of this.configOverrides) {
178
184
  args.push('-c', configOverride);
179
185
  }
186
+ if (this.reasoningEffort) {
187
+ args.push('-c', `model_reasoning_effort="${this.reasoningEffort}"`);
188
+ }
180
189
  for (const addDir of this.buildAddDirs(extraAddDirs)) {
181
190
  args.push('--add-dir', addDir);
182
191
  }
@@ -219,6 +228,9 @@ export class CodexConversationAdapter {
219
228
  for (const configOverride of this.configOverrides) {
220
229
  args.push('-c', configOverride);
221
230
  }
231
+ if (this.reasoningEffort) {
232
+ args.push('-c', `model_reasoning_effort="${this.reasoningEffort}"`);
233
+ }
222
234
  if (execMode.fullAuto) {
223
235
  args.push('--full-auto');
224
236
  }
@@ -3,6 +3,7 @@ export declare class CodexAppServerAdapter {
3
3
  private readonly cwd;
4
4
  private readonly codexBin;
5
5
  private model;
6
+ private reasoningEffort;
6
7
  private readonly sandbox;
7
8
  private readonly legacyApprovalPolicy;
8
9
  private readonly addDirs;
@@ -32,6 +33,7 @@ export declare class CodexAppServerAdapter {
32
33
  threadId?: string | null;
33
34
  codexBin?: string;
34
35
  model?: string | null;
36
+ reasoningEffort?: string | null;
35
37
  sandbox?: CodexSandboxMode | null;
36
38
  approvalPolicy?: CodexApprovalPolicy | null;
37
39
  addDirs?: string[];
@@ -42,6 +44,15 @@ export declare class CodexAppServerAdapter {
42
44
  getThreadId(): string | null;
43
45
  clearThreadId(): void;
44
46
  setModel(model: string | null): void;
47
+ /**
48
+ * Sets GPT reasoning effort, applied via model_reasoning_effort. The config
49
+ * payload is only sent on thread/start and thread/resume (not per turn/start),
50
+ * so a mid-session change must force a re-resume to take effect on the next
51
+ * turn — mirroring the exec transport, which re-sends `-c model_reasoning_effort`
52
+ * every turn. thread/resume reloads the existing thread (excludeTurns), so
53
+ * conversation context is preserved.
54
+ */
55
+ setReasoningEffort(effort: string | null): void;
45
56
  isRunning(): boolean;
46
57
  interrupt(): Promise<void>;
47
58
  close(): void;
@@ -4,6 +4,7 @@ export class CodexAppServerAdapter {
4
4
  cwd;
5
5
  codexBin;
6
6
  model;
7
+ reasoningEffort;
7
8
  sandbox;
8
9
  legacyApprovalPolicy;
9
10
  addDirs;
@@ -33,6 +34,7 @@ export class CodexAppServerAdapter {
33
34
  this.threadId = opts.threadId ?? null;
34
35
  this.codexBin = opts.codexBin ?? 'codex';
35
36
  this.model = opts.model ?? null;
37
+ this.reasoningEffort = opts.reasoningEffort ?? null;
36
38
  this.sandbox = opts.sandbox ?? null;
37
39
  this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
38
40
  this.addDirs = opts.addDirs ?? [];
@@ -51,6 +53,23 @@ export class CodexAppServerAdapter {
51
53
  setModel(model) {
52
54
  this.model = model;
53
55
  }
56
+ /**
57
+ * Sets GPT reasoning effort, applied via model_reasoning_effort. The config
58
+ * payload is only sent on thread/start and thread/resume (not per turn/start),
59
+ * so a mid-session change must force a re-resume to take effect on the next
60
+ * turn — mirroring the exec transport, which re-sends `-c model_reasoning_effort`
61
+ * every turn. thread/resume reloads the existing thread (excludeTurns), so
62
+ * conversation context is preserved.
63
+ */
64
+ setReasoningEffort(effort) {
65
+ const next = effort && effort.trim() ? effort.trim() : null;
66
+ if (next === this.reasoningEffort)
67
+ return;
68
+ this.reasoningEffort = next;
69
+ if (this.threadId && this.loadedThreadId === this.threadId) {
70
+ this.loadedThreadId = null;
71
+ }
72
+ }
54
73
  isRunning() {
55
74
  return this.currentTurnId !== null || this.currentTurnResolve !== null;
56
75
  }
@@ -135,7 +154,7 @@ export class CodexAppServerAdapter {
135
154
  mode: 'plan',
136
155
  settings: {
137
156
  ...(planModel ? { model: planModel } : {}),
138
- reasoning_effort: null,
157
+ reasoning_effort: this.reasoningEffort ?? null,
139
158
  developer_instructions: null,
140
159
  },
141
160
  },
@@ -170,6 +189,9 @@ export class CodexAppServerAdapter {
170
189
  continue;
171
190
  config[key] = parseConfigValue(raw.slice(separator + 1));
172
191
  }
192
+ if (this.reasoningEffort && config.model_reasoning_effort === undefined) {
193
+ config.model_reasoning_effort = this.reasoningEffort;
194
+ }
173
195
  return Object.keys(config).length > 0 ? { config } : {};
174
196
  }
175
197
  rememberResolvedModel(result) {
@@ -387,7 +409,14 @@ export class CodexAppServerAdapter {
387
409
  if (isRecord(status) && status.type === 'failed') {
388
410
  this.currentErrorText = stringifyPreview(turn?.error);
389
411
  }
390
- this.currentOnEvent?.({ type: 'turn.completed' });
412
+ const usage = isRecord(turn?.usage)
413
+ ? {
414
+ ...(typeof turn.usage.input_tokens === 'number' ? { input_tokens: turn.usage.input_tokens } : {}),
415
+ ...(typeof turn.usage.cached_input_tokens === 'number' ? { cached_input_tokens: turn.usage.cached_input_tokens } : {}),
416
+ ...(typeof turn.usage.output_tokens === 'number' ? { output_tokens: turn.usage.output_tokens } : {}),
417
+ }
418
+ : undefined;
419
+ this.currentOnEvent?.({ type: 'turn.completed', ...(usage ? { usage } : {}) });
391
420
  this.resolveCurrentTurn();
392
421
  return;
393
422
  }
@@ -1,4 +1,4 @@
1
- import type { ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk } from '@canonmsg/core';
1
+ import type { ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, CanonUnifiedDiff } from '@canonmsg/core';
2
2
  /**
3
3
  * Mapping for Codex app-server native approval requests.
4
4
  *
@@ -20,6 +20,11 @@ export interface CanonCodexApprovalRequest {
20
20
  riskLevel: 'normal' | 'destructive';
21
21
  native: ApprovalNativeRequestMetadata;
22
22
  details: ApprovalRequestDetail[];
23
+ /**
24
+ * File-change preview parsed from the native patch payload, when the params
25
+ * carried something parseable. Already truncated to the shared budgets.
26
+ */
27
+ diff?: CanonUnifiedDiff;
23
28
  }
24
29
  export type CodexApprovalDecision = {
25
30
  decision: 'accept';
@@ -28,5 +33,11 @@ export type CodexApprovalDecision = {
28
33
  } | {
29
34
  decision: 'decline';
30
35
  };
36
+ /**
37
+ * Best-effort conversion of Codex file-approval params into the shared
38
+ * unified-diff payload, truncated to the standard budgets. Returns undefined
39
+ * (and logs) when nothing parseable arrived — never throws.
40
+ */
41
+ export declare function extractCodexApprovalDiff(params: Record<string, unknown>): CanonUnifiedDiff | undefined;
31
42
  export declare function mapCodexAppServerApprovalRequest(request: CodexNativeApprovalRequest): CanonCodexApprovalRequest | null;
32
43
  export declare function mapCanonApprovalResultToCodexDecision(result: ApprovalResult): CodexApprovalDecision;
@@ -1,3 +1,4 @@
1
+ import { truncateUnifiedDiff } from '@canonmsg/core';
1
2
  function isRecord(value) {
2
3
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
3
4
  }
@@ -105,6 +106,272 @@ function mapCommandRequest(method, params) {
105
106
  ],
106
107
  };
107
108
  }
109
+ // ── File-change diff extraction ─────────────────────────────────────
110
+ //
111
+ // Codex file approvals may carry the pending change as unified-diff text
112
+ // (`patch`/`diff`) or as structured changes (`changes`/`fileChanges` — array
113
+ // entries or a path-keyed map of apply_patch-style variants). Convert whatever
114
+ // arrives into the shared CanonUnifiedDiff payload so Canon approval cards
115
+ // render the change inline. Extraction is best-effort: unknown shapes yield
116
+ // undefined and must never block the approval itself.
117
+ const DIFF_STATUS_BY_KIND = {
118
+ add: 'created',
119
+ added: 'created',
120
+ create: 'created',
121
+ created: 'created',
122
+ new: 'created',
123
+ delete: 'deleted',
124
+ deleted: 'deleted',
125
+ remove: 'deleted',
126
+ removed: 'deleted',
127
+ rename: 'renamed',
128
+ renamed: 'renamed',
129
+ move: 'renamed',
130
+ moved: 'renamed',
131
+ update: 'modified',
132
+ updated: 'modified',
133
+ modify: 'modified',
134
+ modified: 'modified',
135
+ edit: 'modified',
136
+ change: 'modified',
137
+ };
138
+ function stripDiffPathPrefix(path) {
139
+ return path.replace(/^[ab]\//, '');
140
+ }
141
+ function countHunkLines(diffText) {
142
+ let additions = 0;
143
+ let deletions = 0;
144
+ for (const line of diffText.split('\n')) {
145
+ if (line.startsWith('+') && !line.startsWith('+++'))
146
+ additions += 1;
147
+ else if (line.startsWith('-') && !line.startsWith('---'))
148
+ deletions += 1;
149
+ }
150
+ return { additions, deletions };
151
+ }
152
+ /** Drop git/unified headers from per-file diff text so only `@@ …` hunks remain. */
153
+ function toHunkText(text) {
154
+ if (!/^(diff --git |index |--- |\+\+\+ )/m.test(text))
155
+ return text;
156
+ const lines = text.split('\n');
157
+ const start = lines.findIndex((line) => line.startsWith('@@'));
158
+ return start >= 0 ? lines.slice(start).join('\n') : text;
159
+ }
160
+ function parseUnifiedDiffSection(lines) {
161
+ let gitOldPath;
162
+ let gitNewPath;
163
+ let headerOldPath;
164
+ let headerNewPath;
165
+ let created = false;
166
+ let deleted = false;
167
+ let renamed = false;
168
+ let hunkStart = -1;
169
+ for (let i = 0; i < lines.length; i += 1) {
170
+ const line = lines[i];
171
+ if (line.startsWith('@@')) {
172
+ hunkStart = i;
173
+ break;
174
+ }
175
+ const gitHeader = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
176
+ if (gitHeader) {
177
+ gitOldPath = gitHeader[1];
178
+ gitNewPath = gitHeader[2];
179
+ continue;
180
+ }
181
+ if (line.startsWith('new file mode'))
182
+ created = true;
183
+ else if (line.startsWith('deleted file mode'))
184
+ deleted = true;
185
+ else if (line.startsWith('rename from ')) {
186
+ renamed = true;
187
+ headerOldPath = line.slice('rename from '.length).trim();
188
+ }
189
+ else if (line.startsWith('rename to ')) {
190
+ renamed = true;
191
+ headerNewPath = line.slice('rename to '.length).trim();
192
+ }
193
+ else if (line.startsWith('--- ')) {
194
+ const source = line.slice(4).trim();
195
+ if (source === '/dev/null')
196
+ created = true;
197
+ else
198
+ headerOldPath ??= stripDiffPathPrefix(source);
199
+ }
200
+ else if (line.startsWith('+++ ')) {
201
+ const target = line.slice(4).trim();
202
+ if (target === '/dev/null')
203
+ deleted = true;
204
+ else
205
+ headerNewPath ??= stripDiffPathPrefix(target);
206
+ }
207
+ }
208
+ const oldPath = headerOldPath ?? gitOldPath;
209
+ const newPath = headerNewPath ?? gitNewPath;
210
+ const path = (deleted ? oldPath : newPath) ?? newPath ?? oldPath;
211
+ if (!path || !path.trim())
212
+ return null;
213
+ const status = created
214
+ ? 'created'
215
+ : deleted
216
+ ? 'deleted'
217
+ : renamed || (oldPath && newPath && oldPath !== newPath)
218
+ ? 'renamed'
219
+ : 'modified';
220
+ const diffText = hunkStart >= 0 ? lines.slice(hunkStart).join('\n') : undefined;
221
+ const counts = diffText ? countHunkLines(diffText) : undefined;
222
+ return {
223
+ path,
224
+ ...(status === 'renamed' && oldPath ? { oldPath } : {}),
225
+ status,
226
+ ...(diffText ? { diff: diffText } : {}),
227
+ ...(counts ? { additions: counts.additions, deletions: counts.deletions } : {}),
228
+ };
229
+ }
230
+ function splitUnifiedDiffSections(lines) {
231
+ const sections = [];
232
+ let current = null;
233
+ const hasGitHeaders = lines.some((line) => line.startsWith('diff --git '));
234
+ for (let i = 0; i < lines.length; i += 1) {
235
+ const line = lines[i];
236
+ const startsSection = hasGitHeaders
237
+ ? line.startsWith('diff --git ')
238
+ // Bare unified diff: a `--- ` source header immediately followed by `+++ `.
239
+ : line.startsWith('--- ') && lines[i + 1]?.startsWith('+++ ') === true;
240
+ if (startsSection) {
241
+ current = [line];
242
+ sections.push(current);
243
+ continue;
244
+ }
245
+ if (current)
246
+ current.push(line);
247
+ }
248
+ return sections;
249
+ }
250
+ function parseUnifiedDiffText(text) {
251
+ if (!/^(diff --git |--- )/m.test(text))
252
+ return null;
253
+ const files = splitUnifiedDiffSections(text.split('\n'))
254
+ .map(parseUnifiedDiffSection)
255
+ .filter((file) => file !== null);
256
+ return files.length > 0 ? files : null;
257
+ }
258
+ function readChangeDiffText(record) {
259
+ for (const key of ['diff', 'unifiedDiff', 'unified_diff', 'patch']) {
260
+ const value = record[key];
261
+ // Deliberately not trimmed — leading spaces are significant context lines.
262
+ if (typeof value === 'string' && value.trim())
263
+ return value;
264
+ }
265
+ return undefined;
266
+ }
267
+ function mapStructuredChangeEntry(entry) {
268
+ if (!isRecord(entry))
269
+ return null;
270
+ const path = readString(entry, ['path', 'filePath', 'file']);
271
+ if (!path)
272
+ return null;
273
+ const kind = readString(entry, ['kind', 'type', 'status', 'action']);
274
+ const oldPath = readString(entry, ['oldPath', 'old_path', 'movePath', 'move_path', 'from']);
275
+ let status = (kind ? DIFF_STATUS_BY_KIND[kind.toLowerCase()] : undefined) ?? 'modified';
276
+ if (status === 'modified' && oldPath && oldPath !== path)
277
+ status = 'renamed';
278
+ const rawDiff = readChangeDiffText(entry);
279
+ const diffText = rawDiff ? toHunkText(rawDiff) : undefined;
280
+ const counts = diffText ? countHunkLines(diffText) : undefined;
281
+ return {
282
+ path,
283
+ ...(status === 'renamed' && oldPath ? { oldPath } : {}),
284
+ status,
285
+ ...(diffText ? { diff: diffText } : {}),
286
+ ...(counts ? { additions: counts.additions, deletions: counts.deletions } : {}),
287
+ };
288
+ }
289
+ /** Render created-file content as a single all-additions hunk. */
290
+ function synthesizeCreatedFileHunk(content) {
291
+ const lines = content.split('\n');
292
+ // Drop the empty segment a trailing newline leaves behind.
293
+ if (lines.length > 1 && lines[lines.length - 1] === '')
294
+ lines.pop();
295
+ return [`@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)].join('\n');
296
+ }
297
+ /** Map one `{ path → apply_patch variant }` entry (add/delete/update shapes). */
298
+ function mapKeyedFileChange(path, value) {
299
+ const cleanPath = path.trim();
300
+ if (!cleanPath)
301
+ return null;
302
+ if (value === 'delete')
303
+ return { path: cleanPath, status: 'deleted' };
304
+ if (!isRecord(value))
305
+ return null;
306
+ if ('delete' in value)
307
+ return { path: cleanPath, status: 'deleted' };
308
+ if (isRecord(value.add)) {
309
+ const content = typeof value.add.content === 'string' ? value.add.content : undefined;
310
+ const diffText = content ? synthesizeCreatedFileHunk(content) : undefined;
311
+ return {
312
+ path: cleanPath,
313
+ status: 'created',
314
+ ...(diffText ? { diff: diffText, additions: countHunkLines(diffText).additions, deletions: 0 } : {}),
315
+ };
316
+ }
317
+ if (isRecord(value.update)) {
318
+ const movePath = readString(value.update, ['movePath', 'move_path']);
319
+ const rawDiff = readChangeDiffText(value.update);
320
+ const diffText = rawDiff ? toHunkText(rawDiff) : undefined;
321
+ const counts = diffText ? countHunkLines(diffText) : undefined;
322
+ const isRename = Boolean(movePath && movePath !== cleanPath);
323
+ return {
324
+ path: isRename && movePath ? movePath : cleanPath,
325
+ ...(isRename ? { oldPath: cleanPath } : {}),
326
+ status: isRename ? 'renamed' : 'modified',
327
+ ...(diffText ? { diff: diffText } : {}),
328
+ ...(counts ? { additions: counts.additions, deletions: counts.deletions } : {}),
329
+ };
330
+ }
331
+ // Flat record fallback: kind/diff fields live on the value itself.
332
+ return mapStructuredChangeEntry({ path: cleanPath, ...value });
333
+ }
334
+ function extractDiffFiles(params) {
335
+ for (const key of ['patch', 'diff']) {
336
+ const value = params[key];
337
+ if (typeof value === 'string' && value.trim()) {
338
+ const files = parseUnifiedDiffText(value);
339
+ if (files)
340
+ return files;
341
+ }
342
+ }
343
+ const changes = params.changes ?? params.fileChanges;
344
+ if (Array.isArray(changes)) {
345
+ const files = changes
346
+ .map(mapStructuredChangeEntry)
347
+ .filter((file) => file !== null);
348
+ return files.length > 0 ? files : null;
349
+ }
350
+ if (isRecord(changes)) {
351
+ const files = Object.entries(changes)
352
+ .map(([path, value]) => mapKeyedFileChange(path, value))
353
+ .filter((file) => file !== null);
354
+ return files.length > 0 ? files : null;
355
+ }
356
+ return null;
357
+ }
358
+ /**
359
+ * Best-effort conversion of Codex file-approval params into the shared
360
+ * unified-diff payload, truncated to the standard budgets. Returns undefined
361
+ * (and logs) when nothing parseable arrived — never throws.
362
+ */
363
+ export function extractCodexApprovalDiff(params) {
364
+ try {
365
+ const files = extractDiffFiles(params);
366
+ if (!files)
367
+ return undefined;
368
+ return truncateUnifiedDiff({ files });
369
+ }
370
+ catch (error) {
371
+ console.error('[canon-codex] Failed to parse approval diff; omitting preview:', error instanceof Error ? error.message : error);
372
+ return undefined;
373
+ }
374
+ }
108
375
  function mapFileRequest(method, params) {
109
376
  const path = readString(params, ['path', 'filePath', 'targetPath']);
110
377
  const summary = path
@@ -112,6 +379,7 @@ function mapFileRequest(method, params) {
112
379
  ?? stringifyPreview(params.changes ?? params.patch ?? params);
113
380
  const patch = readString(params, ['patch', 'diff']);
114
381
  const destructive = /\b(delete|remove|unlink)\b/i.test(stringifyPreview(params, 1000));
382
+ const diff = extractCodexApprovalDiff(params);
115
383
  return {
116
384
  toolName: method === 'applyPatchApproval' ? 'codex.applyPatch' : 'codex.fileChange',
117
385
  toolInput: {
@@ -127,6 +395,7 @@ function mapFileRequest(method, params) {
127
395
  ...detail('Path', path),
128
396
  ...detail('Patch', patch),
129
397
  ],
398
+ ...(diff ? { diff } : {}),
130
399
  };
131
400
  }
132
401
  function mapPermissionRequest(method, params) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Codex host RTDB `/control/{conversationId}/{agentId}` channel wiring.
3
+ *
4
+ * Configures the shared `ControlChannelPoller` from `@canonmsg/core` to the
5
+ * codex host characterization profile (the "codex host profile" describe
6
+ * block in core's control-poller.test.ts is the contract):
7
+ *
8
+ * - Keys: `session` + `signal` (never `primitive`), read sequentially per
9
+ * conversation, conversations polled one at a time.
10
+ * - Cadence: immediate first cycle, then active/idle delays + jitter with
11
+ * the activity probe sampled BEFORE the cycle runs.
12
+ * - Session controls are always consumed once newer (default consume), even
13
+ * when no live session applied them; signals are NOT consumed when the
14
+ * handler throws (consumeOnError stays false).
15
+ * - Dedupe is primed eagerly via `poller.baseline([conversationId])` at
16
+ * session creation — the second legacy poll site.
17
+ */
18
+ import { ControlChannelPoller, type ControlChannelRTDB, type ControlHandlerResult, type ControlPollerError, type ControlSessionEvent, type ControlSignalEvent } from '@canonmsg/core';
19
+ export declare const CONTROL_POLL_MS = 2000;
20
+ export declare const IDLE_CONTROL_POLL_MS = 10000;
21
+ export declare const CONTROL_POLL_JITTER_MS = 1000;
22
+ export interface CodexControlChannelInput {
23
+ /** Scoped RTDB handle from `initRTDBAuth` — never the module-global default. */
24
+ rtdb: ControlChannelRTDB;
25
+ agentId: string;
26
+ /** Live session conversation ids, snapshotted at the start of every cycle. */
27
+ conversationIds: () => Iterable<string>;
28
+ /** Sampled before each cycle to choose the active vs idle delay. */
29
+ hasActiveWork: () => boolean;
30
+ /**
31
+ * Applies a newer `/session` control node. The node is always consumed
32
+ * after the handler resolves; a thrown error leaves it in place (with
33
+ * dedupe already advanced, so it is never retried).
34
+ */
35
+ onSessionControl: (event: ControlSessionEvent) => Promise<ControlHandlerResult> | ControlHandlerResult;
36
+ /**
37
+ * Handles a newer `/signal` node. Return `{ consume: false }` to leave the
38
+ * node in place; thrown errors also leave it (consumeOnError stays false).
39
+ */
40
+ onSignal: (event: ControlSignalEvent) => Promise<ControlHandlerResult> | ControlHandlerResult;
41
+ onError?: (error: ControlPollerError) => void;
42
+ /** Injectable jitter source (tests). */
43
+ random?: () => number;
44
+ }
45
+ export declare function createCodexControlPoller(input: CodexControlChannelInput): ControlChannelPoller;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Codex host RTDB `/control/{conversationId}/{agentId}` channel wiring.
3
+ *
4
+ * Configures the shared `ControlChannelPoller` from `@canonmsg/core` to the
5
+ * codex host characterization profile (the "codex host profile" describe
6
+ * block in core's control-poller.test.ts is the contract):
7
+ *
8
+ * - Keys: `session` + `signal` (never `primitive`), read sequentially per
9
+ * conversation, conversations polled one at a time.
10
+ * - Cadence: immediate first cycle, then active/idle delays + jitter with
11
+ * the activity probe sampled BEFORE the cycle runs.
12
+ * - Session controls are always consumed once newer (default consume), even
13
+ * when no live session applied them; signals are NOT consumed when the
14
+ * handler throws (consumeOnError stays false).
15
+ * - Dedupe is primed eagerly via `poller.baseline([conversationId])` at
16
+ * session creation — the second legacy poll site.
17
+ */
18
+ import { ControlChannelPoller, } from '@canonmsg/core';
19
+ export const CONTROL_POLL_MS = 2_000;
20
+ export const IDLE_CONTROL_POLL_MS = 10_000;
21
+ export const CONTROL_POLL_JITTER_MS = 1_000;
22
+ export function createCodexControlPoller(input) {
23
+ return new ControlChannelPoller({
24
+ rtdb: input.rtdb,
25
+ agentId: input.agentId,
26
+ conversationIds: input.conversationIds,
27
+ cadence: {
28
+ kind: 'adaptive',
29
+ activeMs: CONTROL_POLL_MS,
30
+ idleMs: IDLE_CONTROL_POLL_MS,
31
+ jitterMs: CONTROL_POLL_JITTER_MS,
32
+ hasActiveWork: input.hasActiveWork,
33
+ activitySample: 'cycle-start',
34
+ },
35
+ pollOnStart: true,
36
+ conversationConcurrency: 'sequential',
37
+ handlers: {
38
+ session: {
39
+ handle: input.onSessionControl,
40
+ },
41
+ signal: {
42
+ handle: input.onSignal,
43
+ },
44
+ },
45
+ ...(input.onError ? { onError: input.onError } : {}),
46
+ ...(input.random ? { random: input.random } : {}),
47
+ });
48
+ }