@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 +4 -0
- package/dist/adapter.js +12 -0
- package/dist/app-server-adapter.d.ts +11 -0
- package/dist/app-server-adapter.js +31 -2
- package/dist/app-server-approval.d.ts +12 -1
- package/dist/app-server-approval.js +269 -0
- package/dist/control-channel.d.ts +45 -0
- package/dist/control-channel.js +48 -0
- package/dist/host.js +196 -132
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
+
}
|