@canonmsg/codex-plugin 0.18.11 → 0.18.12

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.
@@ -1,4 +1,5 @@
1
1
  import type { CodexApprovalPolicy, CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
2
+ export type JsonRecord = Record<string, unknown>;
2
3
  export interface CodexSkillMetadata {
3
4
  name: string;
4
5
  path: string;
@@ -18,6 +19,7 @@ export declare class CodexAppServerAdapter {
18
19
  private readonly configOverrides;
19
20
  private readonly fullAuto;
20
21
  private readonly bypassApprovalsAndSandbox;
22
+ private readonly dynamicTools;
21
23
  private child;
22
24
  private threadId;
23
25
  private loadedThreadId;
@@ -49,6 +51,7 @@ export declare class CodexAppServerAdapter {
49
51
  configOverrides?: string[];
50
52
  fullAuto?: boolean;
51
53
  bypassApprovalsAndSandbox?: boolean;
54
+ dynamicTools?: readonly JsonRecord[];
52
55
  });
53
56
  getThreadId(): string | null;
54
57
  clearThreadId(): void;
@@ -65,6 +68,7 @@ export declare class CodexAppServerAdapter {
65
68
  isRunning(): boolean;
66
69
  interrupt(): Promise<void>;
67
70
  compactThread(): Promise<void>;
71
+ requestAppServer(method: string, params: unknown): Promise<unknown>;
68
72
  close(): void;
69
73
  runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], _extraAddDirs?: readonly string[], options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
70
74
  private resolveApprovalPolicy;
@@ -80,6 +84,7 @@ export declare class CodexAppServerAdapter {
80
84
  private handleLine;
81
85
  private handleServerRequest;
82
86
  private handleNotification;
87
+ private isCurrentThreadNotification;
83
88
  private resolveCurrentTurn;
84
89
  private clearActiveTurn;
85
90
  private sendRequest;
@@ -11,6 +11,7 @@ export class CodexAppServerAdapter {
11
11
  configOverrides;
12
12
  fullAuto;
13
13
  bypassApprovalsAndSandbox;
14
+ dynamicTools;
14
15
  child = null;
15
16
  threadId;
16
17
  loadedThreadId = null;
@@ -42,6 +43,7 @@ export class CodexAppServerAdapter {
42
43
  this.configOverrides = opts.configOverrides ?? [];
43
44
  this.fullAuto = opts.fullAuto ?? false;
44
45
  this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
46
+ this.dynamicTools = opts.dynamicTools ?? [];
45
47
  }
46
48
  getThreadId() {
47
49
  return this.threadId;
@@ -92,6 +94,10 @@ export class CodexAppServerAdapter {
92
94
  threadId: this.threadId,
93
95
  });
94
96
  }
97
+ async requestAppServer(method, params) {
98
+ await this.ensureStarted();
99
+ return await this.sendRequest(method, params);
100
+ }
95
101
  close() {
96
102
  this.child?.kill('SIGTERM');
97
103
  this.child = null;
@@ -122,6 +128,7 @@ export class CodexAppServerAdapter {
122
128
  approvalPolicy: this.resolveApprovalPolicy(),
123
129
  excludeTurns: true,
124
130
  persistExtendedHistory: true,
131
+ ...(this.dynamicTools.length ? { dynamicTools: this.dynamicTools } : {}),
125
132
  });
126
133
  this.loadedThreadId = this.threadId;
127
134
  this.rememberResolvedModel(resumed);
@@ -133,6 +140,7 @@ export class CodexAppServerAdapter {
133
140
  ...(this.sandbox ? { sandbox: this.sandbox } : {}),
134
141
  ...this.configPayload(),
135
142
  approvalPolicy: this.resolveApprovalPolicy(),
143
+ ...(this.dynamicTools.length ? { dynamicTools: this.dynamicTools } : {}),
136
144
  experimentalRawEvents: false,
137
145
  persistExtendedHistory: true,
138
146
  });
@@ -358,16 +366,18 @@ export class CodexAppServerAdapter {
358
366
  }
359
367
  }
360
368
  handleNotification(method, params) {
361
- if (method === 'turn/started') {
362
- this.currentTurnId = readString(params.turn, 'id') ?? this.currentTurnId;
363
- this.currentOnEvent?.({ type: 'turn.started' });
364
- return;
365
- }
366
369
  if (method === 'skills/changed') {
367
370
  this.skillsCache = null;
368
371
  this.currentOnEvent?.({ type: 'skills.changed' });
369
372
  return;
370
373
  }
374
+ if (!this.isCurrentThreadNotification(params))
375
+ return;
376
+ if (method === 'turn/started') {
377
+ this.currentTurnId = readString(params.turn, 'id') ?? this.currentTurnId;
378
+ this.currentOnEvent?.({ type: 'turn.started' });
379
+ return;
380
+ }
371
381
  if (method === 'thread/status/changed') {
372
382
  const status = params.status;
373
383
  if (status?.type === 'active' && Array.isArray(status.activeFlags)) {
@@ -476,6 +486,15 @@ export class CodexAppServerAdapter {
476
486
  this.currentErrorText = stringifyPreview(params);
477
487
  }
478
488
  }
489
+ isCurrentThreadNotification(params) {
490
+ const threadId = readNotificationThreadId(params);
491
+ if (threadId && this.threadId && threadId !== this.threadId)
492
+ return false;
493
+ const turnId = readNotificationTurnId(params);
494
+ if (turnId && this.currentTurnId && turnId !== this.currentTurnId)
495
+ return false;
496
+ return true;
497
+ }
479
498
  resolveCurrentTurn() {
480
499
  const result = {
481
500
  threadId: this.threadId,
@@ -538,6 +557,18 @@ function readRawString(record, key) {
538
557
  const value = record?.[key];
539
558
  return typeof value === 'string' ? value : undefined;
540
559
  }
560
+ function readNotificationThreadId(params) {
561
+ return readString(params, 'threadId')
562
+ ?? readString(params.thread, 'id')
563
+ ?? readString(params.turn, 'threadId')
564
+ ?? readString(params.item, 'threadId')
565
+ ?? readString(params.status, 'threadId');
566
+ }
567
+ function readNotificationTurnId(params) {
568
+ return readString(params, 'turnId')
569
+ ?? readString(params.turn, 'id')
570
+ ?? readString(params.item, 'turnId');
571
+ }
541
572
  function readNullableString(record, key) {
542
573
  const value = record?.[key];
543
574
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -0,0 +1,42 @@
1
+ type JsonRecord = Record<string, unknown>;
2
+ interface DynamicToolSpec {
3
+ [key: string]: unknown;
4
+ namespace: 'codex_app';
5
+ name: string;
6
+ description: string;
7
+ inputSchema: JsonRecord;
8
+ deferLoading?: boolean;
9
+ }
10
+ export interface CodexAppToolAdapter {
11
+ requestAppServer(method: string, params: unknown): Promise<unknown>;
12
+ }
13
+ export interface CodexAppToolWorkspace {
14
+ id: string;
15
+ label: string;
16
+ cwd: string;
17
+ }
18
+ export interface CodexAppToolRuntime {
19
+ adapter: CodexAppToolAdapter;
20
+ currentThreadId: string | null;
21
+ currentCwd: string;
22
+ workspaces: ReadonlyArray<CodexAppToolWorkspace>;
23
+ model?: string | null;
24
+ effort?: string | null;
25
+ }
26
+ export interface CodexAppToolCallParams {
27
+ arguments?: unknown;
28
+ namespace?: unknown;
29
+ tool?: unknown;
30
+ }
31
+ type DynamicToolCallResponse = {
32
+ success: boolean;
33
+ contentItems: Array<{
34
+ type: 'inputText';
35
+ text: string;
36
+ }>;
37
+ };
38
+ export declare const CODEX_APP_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
39
+ export declare function isCodexAppToolCall(params: Record<string, unknown>): boolean;
40
+ export declare function deniedCodexAppToolResult(reason: string): DynamicToolCallResponse;
41
+ export declare function handleCodexAppToolCall(runtime: CodexAppToolRuntime, params: CodexAppToolCallParams): Promise<DynamicToolCallResponse>;
42
+ export {};
@@ -0,0 +1,519 @@
1
+ const emptyObjectSchema = {
2
+ type: 'object',
3
+ properties: {},
4
+ additionalProperties: false,
5
+ };
6
+ const modelProperties = {
7
+ model: {
8
+ type: 'string',
9
+ description: 'Optional model override. Omit unless the user explicitly asks for a specific model.',
10
+ },
11
+ thinking: {
12
+ type: 'string',
13
+ description: 'Optional reasoning effort override.',
14
+ enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
15
+ },
16
+ };
17
+ const createThreadTargetSchema = {
18
+ description: 'Where to create the thread. Canon currently supports local project targets only.',
19
+ anyOf: [
20
+ {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ properties: {
24
+ type: { type: 'string', enum: ['project'] },
25
+ projectId: { type: 'string', description: 'Canon workspace id.' },
26
+ environment: {
27
+ anyOf: [
28
+ {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ properties: {
32
+ type: { type: 'string', enum: ['local'] },
33
+ },
34
+ required: ['type'],
35
+ },
36
+ {
37
+ type: 'object',
38
+ additionalProperties: true,
39
+ properties: {
40
+ type: { type: 'string', enum: ['worktree'] },
41
+ },
42
+ required: ['type'],
43
+ },
44
+ ],
45
+ },
46
+ },
47
+ required: ['type', 'projectId', 'environment'],
48
+ },
49
+ {
50
+ type: 'object',
51
+ additionalProperties: false,
52
+ properties: {
53
+ type: { type: 'string', enum: ['projectless'] },
54
+ directoryName: { type: 'string' },
55
+ },
56
+ required: ['type'],
57
+ },
58
+ ],
59
+ };
60
+ const forkEnvironmentSchema = {
61
+ description: 'Where the fork should run. Canon currently supports same-directory forks only.',
62
+ anyOf: [
63
+ {
64
+ type: 'object',
65
+ additionalProperties: false,
66
+ properties: {
67
+ type: { type: 'string', enum: ['same-directory'] },
68
+ },
69
+ required: ['type'],
70
+ },
71
+ {
72
+ type: 'object',
73
+ additionalProperties: true,
74
+ properties: {
75
+ type: { type: 'string', enum: ['worktree'] },
76
+ },
77
+ required: ['type'],
78
+ },
79
+ ],
80
+ };
81
+ function tool(name, description, inputSchema, deferLoading = true) {
82
+ return { namespace: 'codex_app', name, description, inputSchema, deferLoading };
83
+ }
84
+ export const CODEX_APP_DYNAMIC_TOOLS = [
85
+ tool('automation_update', 'Create, update, view, or delete Codex app automations. Canon exposes the name for compatibility, but does not manage Desktop automations.', {
86
+ type: 'object',
87
+ additionalProperties: false,
88
+ properties: {
89
+ id: { type: 'string' },
90
+ mode: { type: 'string' },
91
+ kind: { type: 'string' },
92
+ name: { type: 'string' },
93
+ prompt: { type: 'string' },
94
+ rrule: { type: 'string' },
95
+ cwds: {
96
+ anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
97
+ },
98
+ destination: { type: 'string' },
99
+ executionEnvironment: { type: 'string' },
100
+ localEnvironmentConfigPath: { type: ['string', 'null'] },
101
+ model: { type: 'string' },
102
+ reasoningEffort: { type: 'string' },
103
+ targetThreadId: { type: 'string' },
104
+ status: { type: 'string' },
105
+ },
106
+ }),
107
+ tool('navigate_to_codex_page', 'Navigate the Codex Desktop UI. Canon exposes the name for compatibility, but has no Codex Desktop page to navigate.', { type: 'object', additionalProperties: true, properties: {} }),
108
+ tool('read_thread_terminal', 'Read the Codex Desktop terminal output for this thread. Canon exposes the name for compatibility, but has no Desktop terminal pane.', emptyObjectSchema, false),
109
+ tool('load_workspace_dependencies', 'Locate bundled Desktop workspace dependency runtimes. Canon exposes the name for compatibility, but does not provide Desktop bundle paths.', emptyObjectSchema, false),
110
+ tool('fork_thread', 'Fork a Codex thread. Omit threadId to fork the calling thread. Canon supports same-directory forks.', {
111
+ type: 'object',
112
+ additionalProperties: false,
113
+ properties: {
114
+ threadId: { type: 'string' },
115
+ environment: forkEnvironmentSchema,
116
+ },
117
+ }),
118
+ tool('handoff_thread', 'Move a Codex thread between a checkout and worktree. Canon exposes the name for compatibility, but does not manage Desktop handoffs.', {
119
+ type: 'object',
120
+ additionalProperties: false,
121
+ properties: { threadId: { type: 'string' } },
122
+ required: ['threadId'],
123
+ }),
124
+ tool('get_handoff_status', 'Read Codex Desktop handoff status. Canon exposes the name for compatibility, but does not manage Desktop handoffs.', {
125
+ type: 'object',
126
+ additionalProperties: false,
127
+ properties: { threadId: { type: 'string' } },
128
+ }),
129
+ tool('list_projects', 'List Canon workspaces available to Codex app tools.', emptyObjectSchema),
130
+ tool('create_thread', 'Create a separate Codex thread only when the user explicitly asks for a new or separate thread. Canon supports local project targets.', {
131
+ type: 'object',
132
+ additionalProperties: false,
133
+ properties: {
134
+ prompt: { type: 'string', description: 'Initial prompt for the new thread.' },
135
+ target: createThreadTargetSchema,
136
+ ...modelProperties,
137
+ },
138
+ required: ['prompt'],
139
+ }),
140
+ tool('list_threads', 'List recent Codex threads in Canon-configured workspaces.', {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: {
144
+ query: { type: 'string' },
145
+ limit: { type: 'number' },
146
+ archived: { type: 'boolean' },
147
+ },
148
+ }),
149
+ tool('read_thread', 'Read recent status and turn summaries for one Codex thread without opening it.', {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ threadId: { type: 'string' },
154
+ },
155
+ required: ['threadId'],
156
+ }),
157
+ tool('send_message_to_thread', 'Send a follow-up prompt to an existing Codex thread in the background.', {
158
+ type: 'object',
159
+ additionalProperties: false,
160
+ properties: {
161
+ threadId: { type: 'string' },
162
+ prompt: { type: 'string' },
163
+ ...modelProperties,
164
+ },
165
+ required: ['threadId', 'prompt'],
166
+ }),
167
+ tool('set_thread_pinned', 'Pin or unpin a Codex thread. Canon exposes the name for compatibility, but pinned state is Desktop-only.', {
168
+ type: 'object',
169
+ additionalProperties: false,
170
+ properties: {
171
+ threadId: { type: 'string' },
172
+ pinned: { type: 'boolean' },
173
+ },
174
+ required: ['threadId', 'pinned'],
175
+ }),
176
+ tool('set_thread_archived', 'Archive or unarchive a Codex thread.', {
177
+ type: 'object',
178
+ additionalProperties: false,
179
+ properties: {
180
+ threadId: { type: 'string' },
181
+ archived: { type: 'boolean' },
182
+ },
183
+ required: ['archived'],
184
+ }),
185
+ tool('set_thread_title', 'Rename a Codex thread.', {
186
+ type: 'object',
187
+ additionalProperties: false,
188
+ properties: {
189
+ threadId: { type: 'string' },
190
+ title: { type: 'string' },
191
+ },
192
+ required: ['threadId', 'title'],
193
+ }),
194
+ ];
195
+ const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
196
+ const UNSUPPORTED_TOOLS = new Map([
197
+ ['automation_update', 'Canon does not manage Codex Desktop automations.'],
198
+ ['navigate_to_codex_page', 'Canon has no Codex Desktop page to navigate.'],
199
+ ['read_thread_terminal', 'Canon has no Codex Desktop terminal pane to read.'],
200
+ ['load_workspace_dependencies', 'Canon does not provide Codex Desktop bundled dependency paths.'],
201
+ ['handoff_thread', 'Canon does not manage Codex Desktop handoffs.'],
202
+ ['get_handoff_status', 'Canon does not manage Codex Desktop handoffs.'],
203
+ ['set_thread_pinned', 'Pinned thread state is Codex Desktop-only.'],
204
+ ]);
205
+ export function isCodexAppToolCall(params) {
206
+ const namespace = typeof params.namespace === 'string' ? params.namespace : null;
207
+ const rawTool = typeof params.tool === 'string' ? params.tool.trim() : '';
208
+ const toolName = normalizeToolName(params.tool);
209
+ if (namespace && namespace !== 'codex_app')
210
+ return rawTool.startsWith('codex_app.');
211
+ return namespace === 'codex_app'
212
+ || rawTool.startsWith('codex_app.')
213
+ || (toolName ? CODEX_APP_TOOL_NAMES.has(toolName) : false);
214
+ }
215
+ export function deniedCodexAppToolResult(reason) {
216
+ return toolResult(false, { error: reason });
217
+ }
218
+ export async function handleCodexAppToolCall(runtime, params) {
219
+ const toolName = normalizeToolName(params.tool);
220
+ if (!toolName || !CODEX_APP_TOOL_NAMES.has(toolName)) {
221
+ return toolResult(false, { error: `Unsupported codex_app tool: ${String(params.tool ?? 'unknown')}` });
222
+ }
223
+ const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
224
+ if (unsupportedReason) {
225
+ return toolResult(false, { tool: toolName, error: unsupportedReason });
226
+ }
227
+ const args = parseToolArguments(params.arguments);
228
+ try {
229
+ switch (toolName) {
230
+ case 'list_projects':
231
+ return toolResult(true, {
232
+ projects: listToolWorkspaces(runtime).map((workspace) => ({
233
+ id: workspace.id,
234
+ label: workspace.label,
235
+ cwd: workspace.cwd,
236
+ environments: ['local'],
237
+ })),
238
+ unsupported: {
239
+ worktreeEnvironments: 'Canon host app-server tools only create local project threads.',
240
+ },
241
+ });
242
+ case 'create_thread':
243
+ return await createThread(runtime, args);
244
+ case 'fork_thread':
245
+ return await forkThread(runtime, args);
246
+ case 'list_threads':
247
+ return await listThreads(runtime, args);
248
+ case 'read_thread':
249
+ return await readThread(runtime, args);
250
+ case 'send_message_to_thread':
251
+ return await sendMessageToThread(runtime, args);
252
+ case 'set_thread_archived':
253
+ return await setThreadArchived(runtime, args);
254
+ case 'set_thread_title':
255
+ return await setThreadTitle(runtime, args);
256
+ default:
257
+ return toolResult(false, { tool: toolName, error: 'Tool is registered but has no Canon handler.' });
258
+ }
259
+ }
260
+ catch (error) {
261
+ return toolResult(false, {
262
+ tool: toolName,
263
+ error: error instanceof Error ? error.message : String(error),
264
+ });
265
+ }
266
+ }
267
+ async function createThread(runtime, args) {
268
+ const prompt = readString(args, 'prompt');
269
+ if (!prompt)
270
+ throw new Error('create_thread requires a non-empty prompt.');
271
+ const cwd = resolveCreateThreadCwd(runtime, args);
272
+ const started = await runtime.adapter.requestAppServer('thread/start', {
273
+ cwd,
274
+ ...(readString(args, 'model') ?? runtime.model ? { model: readString(args, 'model') ?? runtime.model } : {}),
275
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
276
+ experimentalRawEvents: false,
277
+ persistExtendedHistory: true,
278
+ });
279
+ const threadId = readThreadId(started);
280
+ if (!threadId)
281
+ throw new Error('Codex app-server did not return a thread id for the new thread.');
282
+ const turnStarted = await runtime.adapter.requestAppServer('turn/start', buildTurnStartParams({
283
+ threadId,
284
+ prompt,
285
+ model: readString(args, 'model') ?? null,
286
+ effort: readString(args, 'thinking') ?? readString(args, 'effort') ?? runtime.effort ?? null,
287
+ }));
288
+ return toolResult(true, {
289
+ threadId,
290
+ turnId: readTurnId(turnStarted) ?? null,
291
+ status: 'submitted',
292
+ cwd,
293
+ });
294
+ }
295
+ async function forkThread(runtime, args) {
296
+ const environment = isRecord(args.environment) ? args.environment : null;
297
+ const environmentType = readString(environment, 'type');
298
+ if (environmentType && environmentType !== 'same-directory') {
299
+ throw new Error('Canon only supports same-directory codex_app.fork_thread calls.');
300
+ }
301
+ const sourceThreadId = readString(args, 'threadId') ?? runtime.currentThreadId;
302
+ if (!sourceThreadId)
303
+ throw new Error('fork_thread requires a source thread id.');
304
+ const source = await assertThreadAllowed(runtime, sourceThreadId);
305
+ const forked = await runtime.adapter.requestAppServer('thread/fork', {
306
+ threadId: sourceThreadId,
307
+ cwd: source.cwd ?? runtime.currentCwd,
308
+ excludeTurns: true,
309
+ persistExtendedHistory: true,
310
+ });
311
+ const threadId = readThreadId(forked);
312
+ if (!threadId)
313
+ throw new Error('Codex app-server did not return a forked thread id.');
314
+ return toolResult(true, {
315
+ threadId,
316
+ sourceThreadId,
317
+ status: 'forked',
318
+ cwd: source.cwd ?? runtime.currentCwd,
319
+ });
320
+ }
321
+ async function listThreads(runtime, args) {
322
+ const limit = clampLimit(readNumber(args, 'limit'));
323
+ const result = await runtime.adapter.requestAppServer('thread/list', {
324
+ cwd: allowedCwds(runtime),
325
+ limit,
326
+ searchTerm: readString(args, 'query') ?? readString(args, 'searchTerm') ?? null,
327
+ archived: typeof args.archived === 'boolean' ? args.archived : false,
328
+ });
329
+ return toolResult(true, result);
330
+ }
331
+ async function readThread(runtime, args) {
332
+ const threadId = readString(args, 'threadId');
333
+ if (!threadId)
334
+ throw new Error('read_thread requires threadId.');
335
+ await assertThreadAllowed(runtime, threadId);
336
+ const result = await runtime.adapter.requestAppServer('thread/read', {
337
+ threadId,
338
+ includeTurns: true,
339
+ });
340
+ return toolResult(true, result);
341
+ }
342
+ async function sendMessageToThread(runtime, args) {
343
+ const threadId = readString(args, 'threadId');
344
+ const prompt = readString(args, 'prompt');
345
+ if (!threadId)
346
+ throw new Error('send_message_to_thread requires threadId.');
347
+ if (!prompt)
348
+ throw new Error('send_message_to_thread requires a non-empty prompt.');
349
+ await assertThreadAllowed(runtime, threadId);
350
+ const result = await runtime.adapter.requestAppServer('turn/start', buildTurnStartParams({
351
+ threadId,
352
+ prompt,
353
+ model: readString(args, 'model') ?? null,
354
+ effort: readString(args, 'thinking') ?? readString(args, 'effort') ?? null,
355
+ }));
356
+ return toolResult(true, {
357
+ threadId,
358
+ turnId: readTurnId(result) ?? null,
359
+ status: 'submitted',
360
+ });
361
+ }
362
+ async function setThreadArchived(runtime, args) {
363
+ const threadId = readString(args, 'threadId') ?? runtime.currentThreadId;
364
+ if (!threadId)
365
+ throw new Error('set_thread_archived requires threadId outside the calling thread.');
366
+ if (typeof args.archived !== 'boolean')
367
+ throw new Error('set_thread_archived requires archived boolean.');
368
+ await assertThreadAllowed(runtime, threadId);
369
+ const result = await runtime.adapter.requestAppServer(args.archived ? 'thread/archive' : 'thread/unarchive', { threadId });
370
+ return toolResult(true, { threadId, archived: args.archived, result });
371
+ }
372
+ async function setThreadTitle(runtime, args) {
373
+ const threadId = readString(args, 'threadId');
374
+ const title = readString(args, 'title');
375
+ if (!threadId)
376
+ throw new Error('set_thread_title requires threadId.');
377
+ if (!title)
378
+ throw new Error('set_thread_title requires a non-empty title.');
379
+ await assertThreadAllowed(runtime, threadId);
380
+ const result = await runtime.adapter.requestAppServer('thread/name/set', {
381
+ threadId,
382
+ name: title,
383
+ });
384
+ return toolResult(true, { threadId, title, result });
385
+ }
386
+ function resolveCreateThreadCwd(runtime, args) {
387
+ const target = isRecord(args.target) ? args.target : null;
388
+ const environment = isRecord(target?.environment) ? target.environment : null;
389
+ const environmentType = readString(environment, 'type');
390
+ if (environmentType === 'worktree') {
391
+ throw new Error('Canon does not create codex_app threads in new worktrees yet.');
392
+ }
393
+ if (readString(target, 'type') === 'projectless') {
394
+ throw new Error('Canon does not support projectless codex_app threads yet.');
395
+ }
396
+ const projectId = readString(target, 'projectId')
397
+ ?? readString(args, 'projectId')
398
+ ?? readString(args, 'workspaceId');
399
+ if (!projectId)
400
+ return runtime.currentCwd;
401
+ const workspace = listToolWorkspaces(runtime).find((entry) => entry.id === projectId);
402
+ if (!workspace)
403
+ throw new Error(`Unknown Canon workspace/project id: ${projectId}`);
404
+ return workspace.cwd;
405
+ }
406
+ async function assertThreadAllowed(runtime, threadId) {
407
+ if (threadId === runtime.currentThreadId) {
408
+ return { cwd: runtime.currentCwd, raw: null };
409
+ }
410
+ const result = await runtime.adapter.requestAppServer('thread/read', {
411
+ threadId,
412
+ includeTurns: false,
413
+ });
414
+ const cwd = readThreadCwd(result);
415
+ if (!cwd || !allowedCwdSet(runtime).has(cwd)) {
416
+ throw new Error(`Thread ${threadId} is not in a Canon-configured workspace.`);
417
+ }
418
+ return { cwd, raw: result };
419
+ }
420
+ function buildTurnStartParams(input) {
421
+ return {
422
+ threadId: input.threadId,
423
+ input: [{ type: 'text', text: input.prompt, text_elements: [] }],
424
+ ...(input.model ? { model: input.model } : {}),
425
+ ...(input.effort ? { effort: input.effort } : {}),
426
+ };
427
+ }
428
+ function listToolWorkspaces(runtime) {
429
+ const seen = new Set();
430
+ const workspaces = [];
431
+ for (const workspace of runtime.workspaces) {
432
+ if (!workspace.cwd || seen.has(workspace.cwd))
433
+ continue;
434
+ seen.add(workspace.cwd);
435
+ workspaces.push(workspace);
436
+ }
437
+ if (runtime.currentCwd && !seen.has(runtime.currentCwd)) {
438
+ workspaces.unshift({
439
+ id: 'current',
440
+ label: 'Current session',
441
+ cwd: runtime.currentCwd,
442
+ });
443
+ }
444
+ return workspaces;
445
+ }
446
+ function allowedCwds(runtime) {
447
+ return listToolWorkspaces(runtime).map((workspace) => workspace.cwd);
448
+ }
449
+ function allowedCwdSet(runtime) {
450
+ return new Set(allowedCwds(runtime));
451
+ }
452
+ function clampLimit(value) {
453
+ if (!value || !Number.isFinite(value))
454
+ return 20;
455
+ return Math.max(1, Math.min(100, Math.floor(value)));
456
+ }
457
+ function parseToolArguments(value) {
458
+ if (isRecord(value))
459
+ return value;
460
+ if (typeof value === 'string' && value.trim()) {
461
+ try {
462
+ const parsed = JSON.parse(value);
463
+ return isRecord(parsed) ? parsed : {};
464
+ }
465
+ catch {
466
+ return {};
467
+ }
468
+ }
469
+ return {};
470
+ }
471
+ function normalizeToolName(value) {
472
+ if (typeof value !== 'string' || !value.trim())
473
+ return null;
474
+ const trimmed = value.trim();
475
+ return trimmed.startsWith('codex_app.') ? trimmed.slice('codex_app.'.length) : trimmed;
476
+ }
477
+ function readThreadId(value) {
478
+ if (!isRecord(value))
479
+ return undefined;
480
+ return readString(value, 'threadId')
481
+ ?? readString(value, 'id')
482
+ ?? readString(isRecord(value.thread) ? value.thread : undefined, 'id');
483
+ }
484
+ function readTurnId(value) {
485
+ if (!isRecord(value))
486
+ return undefined;
487
+ return readString(value, 'turnId')
488
+ ?? readString(value, 'id')
489
+ ?? readString(isRecord(value.turn) ? value.turn : undefined, 'id');
490
+ }
491
+ function readThreadCwd(value) {
492
+ if (!isRecord(value))
493
+ return null;
494
+ const thread = isRecord(value.thread) ? value.thread : undefined;
495
+ return readString(thread, 'cwd')
496
+ ?? readString(thread, 'workingDirectory')
497
+ ?? readString(value, 'cwd')
498
+ ?? null;
499
+ }
500
+ function readString(record, key) {
501
+ const value = record?.[key];
502
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
503
+ }
504
+ function readNumber(record, key) {
505
+ const value = record[key];
506
+ return typeof value === 'number' ? value : undefined;
507
+ }
508
+ function isRecord(value) {
509
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
510
+ }
511
+ function toolResult(success, payload) {
512
+ return {
513
+ success,
514
+ contentItems: [{
515
+ type: 'inputText',
516
+ text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
517
+ }],
518
+ };
519
+ }
package/dist/host.js CHANGED
@@ -10,6 +10,7 @@ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_
10
10
  import { decideAutoReply, } from './inbound-policy.js';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter } from './app-server-adapter.js';
13
+ import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
13
14
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
14
15
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
15
16
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
@@ -849,6 +850,7 @@ export async function main() {
849
850
  session.currentTurnId = null;
850
851
  session.currentTurnOpenedAt = null;
851
852
  session.currentTurnUpdatedAt = null;
853
+ session.currentTurnCanUseCodexAppTools = false;
852
854
  session.lastAcceptedIntent = null;
853
855
  session.resetRequested = false;
854
856
  }
@@ -923,6 +925,7 @@ export async function main() {
923
925
  configOverrides: args.config ?? [],
924
926
  fullAuto: policy.fullAuto,
925
927
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
928
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
926
929
  })
927
930
  : new CodexConversationAdapter({
928
931
  cwd: sessionCwd,
@@ -955,6 +958,7 @@ export async function main() {
955
958
  currentTurnId: null,
956
959
  currentTurnOpenedAt: null,
957
960
  currentTurnUpdatedAt: null,
961
+ currentTurnCanUseCodexAppTools: false,
958
962
  activeSelfContextId: null,
959
963
  lastAcceptedIntent: null,
960
964
  resetRequested: false,
@@ -985,7 +989,7 @@ export async function main() {
985
989
  pendingSessionCreations.delete(conversationId);
986
990
  }
987
991
  }
988
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled') {
992
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
989
993
  const nextPrompt = {
990
994
  prompt,
991
995
  intent,
@@ -995,6 +999,7 @@ export async function main() {
995
999
  mediaAddDirs,
996
1000
  planMode,
997
1001
  artifactRoutingMode,
1002
+ canUseCodexAppTools,
998
1003
  };
999
1004
  if (toFront) {
1000
1005
  session.queue.unshift(nextPrompt);
@@ -1098,6 +1103,22 @@ export async function main() {
1098
1103
  const requestId = String(request.id);
1099
1104
  const params = request.params;
1100
1105
  const expiresAt = Date.now() + 30 * 60_000;
1106
+ if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1107
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1108
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1109
+ }
1110
+ if (!session.currentTurnCanUseCodexAppTools) {
1111
+ return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1112
+ }
1113
+ return await handleCodexAppToolCall({
1114
+ adapter: session.adapter,
1115
+ currentThreadId: session.adapter.getThreadId(),
1116
+ currentCwd: session.cwd,
1117
+ workspaces: workspaceOptions,
1118
+ model: session.state.model ?? null,
1119
+ effort: session.state.effort ?? null,
1120
+ }, params);
1121
+ }
1101
1122
  const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
1102
1123
  if (runtimeCardPayload) {
1103
1124
  const card = parseRuntimeCardV1(runtimeCardPayload);
@@ -1287,7 +1308,7 @@ export async function main() {
1287
1308
  : decision === 'reject'
1288
1309
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1289
1310
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1290
- enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
1311
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
1291
1312
  return;
1292
1313
  }
1293
1314
  let materialized = [];
@@ -1384,7 +1405,7 @@ export async function main() {
1384
1405
  });
1385
1406
  if (session.running && deliveryIntent === 'interrupt') {
1386
1407
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1387
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
1408
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1388
1409
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1389
1410
  await session.adapter.interrupt().catch(() => { });
1390
1411
  clearStreaming(input.conversationId);
@@ -1392,7 +1413,7 @@ export async function main() {
1392
1413
  return;
1393
1414
  }
1394
1415
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1395
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
1416
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1396
1417
  }
1397
1418
  function sendTurnArtifactFile(session, file) {
1398
1419
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1450,6 +1471,7 @@ export async function main() {
1450
1471
  session.turnCommandBlocks = createCommandBlockTracker();
1451
1472
  session.currentTurnOpenedAt = Date.now();
1452
1473
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1474
+ session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1453
1475
  session.lastAcceptedIntent = nextTurn.intent;
1454
1476
  session.turnState = 'thinking';
1455
1477
  session.lastActivity = Date.now();
@@ -1715,6 +1737,7 @@ export async function main() {
1715
1737
  session.currentTurnId = null;
1716
1738
  session.currentTurnOpenedAt = null;
1717
1739
  session.currentTurnUpdatedAt = null;
1740
+ session.currentTurnCanUseCodexAppTools = false;
1718
1741
  session.lastAcceptedIntent = null;
1719
1742
  session.resetRequested = false;
1720
1743
  session.lastActivity = Date.now();
@@ -17,7 +17,7 @@ function saveStore(store) {
17
17
  }
18
18
  export function buildCodexThreadPolicyFingerprint(input) {
19
19
  return createHash('sha256').update(JSON.stringify({
20
- version: 1,
20
+ version: 2,
21
21
  baseCwd: input.baseCwd,
22
22
  executionMode: input.executionMode ?? null,
23
23
  permissionMode: input.permissionMode ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.18.11",
3
+ "version": "0.18.12",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,9 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^3.4.1",
32
+ "@canonmsg/agent-sdk": "^3.4.2",
33
33
  "@canonmsg/coding-agent-host": "^0.2.2",
34
- "@canonmsg/core": "^2.9.2"
34
+ "@canonmsg/core": "^3.0.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"