@canonmsg/codex-plugin 0.11.11 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +16 -1
- package/dist/adapter.js +1 -1
- package/dist/app-server-adapter.d.ts +62 -0
- package/dist/app-server-adapter.js +502 -0
- package/dist/host.js +394 -64
- package/package.json +3 -3
package/dist/adapter.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export type CodexEvent = {
|
|
|
8
8
|
} | {
|
|
9
9
|
type: 'message';
|
|
10
10
|
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'plan.updated';
|
|
13
|
+
text: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'waiting';
|
|
16
|
+
reason: string;
|
|
11
17
|
} | {
|
|
12
18
|
type: 'command.started';
|
|
13
19
|
command: string;
|
|
@@ -24,6 +30,15 @@ export type CodexEvent = {
|
|
|
24
30
|
output_tokens?: number;
|
|
25
31
|
};
|
|
26
32
|
};
|
|
33
|
+
export interface CodexServerRequest {
|
|
34
|
+
id: string | number;
|
|
35
|
+
method: string;
|
|
36
|
+
params: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
export interface CodexRunTurnOptions {
|
|
39
|
+
planMode?: boolean;
|
|
40
|
+
onServerRequest?: (request: CodexServerRequest) => Promise<unknown>;
|
|
41
|
+
}
|
|
27
42
|
export interface CodexTurnResult {
|
|
28
43
|
threadId: string | null;
|
|
29
44
|
finalMessage: string | null;
|
|
@@ -64,7 +79,7 @@ export declare class CodexConversationAdapter {
|
|
|
64
79
|
setModel(model: string | null): void;
|
|
65
80
|
isRunning(): boolean;
|
|
66
81
|
interrupt(): Promise<void>;
|
|
67
|
-
runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[]): Promise<CodexTurnResult>;
|
|
82
|
+
runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], extraAddDirs?: readonly string[], _options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
|
|
68
83
|
private buildAddDirs;
|
|
69
84
|
private buildArgs;
|
|
70
85
|
private canResumeWithCurrentPolicy;
|
package/dist/adapter.js
CHANGED
|
@@ -51,7 +51,7 @@ export class CodexConversationAdapter {
|
|
|
51
51
|
this.child.kill('SIGKILL');
|
|
52
52
|
}, 5_000);
|
|
53
53
|
}
|
|
54
|
-
async runTurn(prompt, onEvent, onLog, imagePaths = [], extraAddDirs = []) {
|
|
54
|
+
async runTurn(prompt, onEvent, onLog, imagePaths = [], extraAddDirs = [], _options = {}) {
|
|
55
55
|
if (this.child) {
|
|
56
56
|
throw new Error('A Codex turn is already in progress for this conversation');
|
|
57
57
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CodexApprovalPolicy, CodexEvent, CodexRunTurnOptions, CodexSandboxMode, CodexTurnResult } from './adapter.js';
|
|
2
|
+
export declare class CodexAppServerAdapter {
|
|
3
|
+
private readonly cwd;
|
|
4
|
+
private readonly codexBin;
|
|
5
|
+
private model;
|
|
6
|
+
private readonly sandbox;
|
|
7
|
+
private readonly legacyApprovalPolicy;
|
|
8
|
+
private readonly addDirs;
|
|
9
|
+
private readonly configOverrides;
|
|
10
|
+
private readonly fullAuto;
|
|
11
|
+
private readonly bypassApprovalsAndSandbox;
|
|
12
|
+
private child;
|
|
13
|
+
private threadId;
|
|
14
|
+
private loadedThreadId;
|
|
15
|
+
private resolvedModel;
|
|
16
|
+
private currentTurnId;
|
|
17
|
+
private requestSeq;
|
|
18
|
+
private pending;
|
|
19
|
+
private currentOnEvent;
|
|
20
|
+
private currentOnLog;
|
|
21
|
+
private currentRequestHandler;
|
|
22
|
+
private currentTurnResolve;
|
|
23
|
+
private currentTurnReject;
|
|
24
|
+
private currentFinalMessage;
|
|
25
|
+
private currentErrorText;
|
|
26
|
+
private interrupted;
|
|
27
|
+
private initialized;
|
|
28
|
+
private messageTextByItem;
|
|
29
|
+
private planText;
|
|
30
|
+
constructor(opts: {
|
|
31
|
+
cwd: string;
|
|
32
|
+
threadId?: string | null;
|
|
33
|
+
codexBin?: string;
|
|
34
|
+
model?: string | null;
|
|
35
|
+
sandbox?: CodexSandboxMode | null;
|
|
36
|
+
approvalPolicy?: CodexApprovalPolicy | null;
|
|
37
|
+
addDirs?: string[];
|
|
38
|
+
configOverrides?: string[];
|
|
39
|
+
fullAuto?: boolean;
|
|
40
|
+
bypassApprovalsAndSandbox?: boolean;
|
|
41
|
+
});
|
|
42
|
+
getThreadId(): string | null;
|
|
43
|
+
clearThreadId(): void;
|
|
44
|
+
setModel(model: string | null): void;
|
|
45
|
+
isRunning(): boolean;
|
|
46
|
+
interrupt(): Promise<void>;
|
|
47
|
+
close(): void;
|
|
48
|
+
runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[], _extraAddDirs?: readonly string[], options?: CodexRunTurnOptions): Promise<CodexTurnResult>;
|
|
49
|
+
private resolveApprovalPolicy;
|
|
50
|
+
private configPayload;
|
|
51
|
+
private rememberResolvedModel;
|
|
52
|
+
private sandboxPolicyPayload;
|
|
53
|
+
private buildWritableRoots;
|
|
54
|
+
private ensureStarted;
|
|
55
|
+
private handleLine;
|
|
56
|
+
private handleServerRequest;
|
|
57
|
+
private handleNotification;
|
|
58
|
+
private resolveCurrentTurn;
|
|
59
|
+
private clearActiveTurn;
|
|
60
|
+
private sendRequest;
|
|
61
|
+
private write;
|
|
62
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
export class CodexAppServerAdapter {
|
|
4
|
+
cwd;
|
|
5
|
+
codexBin;
|
|
6
|
+
model;
|
|
7
|
+
sandbox;
|
|
8
|
+
legacyApprovalPolicy;
|
|
9
|
+
addDirs;
|
|
10
|
+
configOverrides;
|
|
11
|
+
fullAuto;
|
|
12
|
+
bypassApprovalsAndSandbox;
|
|
13
|
+
child = null;
|
|
14
|
+
threadId;
|
|
15
|
+
loadedThreadId = null;
|
|
16
|
+
resolvedModel = null;
|
|
17
|
+
currentTurnId = null;
|
|
18
|
+
requestSeq = 1;
|
|
19
|
+
pending = new Map();
|
|
20
|
+
currentOnEvent = null;
|
|
21
|
+
currentOnLog = null;
|
|
22
|
+
currentRequestHandler = null;
|
|
23
|
+
currentTurnResolve = null;
|
|
24
|
+
currentTurnReject = null;
|
|
25
|
+
currentFinalMessage = null;
|
|
26
|
+
currentErrorText = null;
|
|
27
|
+
interrupted = false;
|
|
28
|
+
initialized = false;
|
|
29
|
+
messageTextByItem = new Map();
|
|
30
|
+
planText = '';
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.cwd = opts.cwd;
|
|
33
|
+
this.threadId = opts.threadId ?? null;
|
|
34
|
+
this.codexBin = opts.codexBin ?? 'codex';
|
|
35
|
+
this.model = opts.model ?? null;
|
|
36
|
+
this.sandbox = opts.sandbox ?? null;
|
|
37
|
+
this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
|
|
38
|
+
this.addDirs = opts.addDirs ?? [];
|
|
39
|
+
this.configOverrides = opts.configOverrides ?? [];
|
|
40
|
+
this.fullAuto = opts.fullAuto ?? false;
|
|
41
|
+
this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
|
|
42
|
+
}
|
|
43
|
+
getThreadId() {
|
|
44
|
+
return this.threadId;
|
|
45
|
+
}
|
|
46
|
+
clearThreadId() {
|
|
47
|
+
this.threadId = null;
|
|
48
|
+
this.loadedThreadId = null;
|
|
49
|
+
this.resolvedModel = null;
|
|
50
|
+
}
|
|
51
|
+
setModel(model) {
|
|
52
|
+
this.model = model;
|
|
53
|
+
}
|
|
54
|
+
isRunning() {
|
|
55
|
+
return this.currentTurnId !== null || this.currentTurnResolve !== null;
|
|
56
|
+
}
|
|
57
|
+
async interrupt() {
|
|
58
|
+
if (!this.threadId || !this.currentTurnId)
|
|
59
|
+
return;
|
|
60
|
+
this.interrupted = true;
|
|
61
|
+
await this.sendRequest('turn/interrupt', {
|
|
62
|
+
threadId: this.threadId,
|
|
63
|
+
turnId: this.currentTurnId,
|
|
64
|
+
}).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
close() {
|
|
67
|
+
this.child?.kill('SIGTERM');
|
|
68
|
+
this.child = null;
|
|
69
|
+
this.initialized = false;
|
|
70
|
+
this.clearActiveTurn();
|
|
71
|
+
}
|
|
72
|
+
async runTurn(prompt, onEvent, onLog, imagePaths = [], _extraAddDirs = [], options = {}) {
|
|
73
|
+
if (this.currentTurnId || this.currentTurnResolve) {
|
|
74
|
+
throw new Error('A Codex turn is already in progress for this conversation');
|
|
75
|
+
}
|
|
76
|
+
await this.ensureStarted();
|
|
77
|
+
this.currentOnEvent = onEvent;
|
|
78
|
+
this.currentOnLog = onLog ?? null;
|
|
79
|
+
this.currentRequestHandler = options.onServerRequest ?? null;
|
|
80
|
+
this.currentFinalMessage = null;
|
|
81
|
+
this.currentErrorText = null;
|
|
82
|
+
this.interrupted = false;
|
|
83
|
+
this.messageTextByItem.clear();
|
|
84
|
+
this.planText = '';
|
|
85
|
+
try {
|
|
86
|
+
if (this.threadId && this.loadedThreadId !== this.threadId) {
|
|
87
|
+
const resumed = await this.sendRequest('thread/resume', {
|
|
88
|
+
threadId: this.threadId,
|
|
89
|
+
cwd: this.cwd,
|
|
90
|
+
...(this.model ? { model: this.model } : {}),
|
|
91
|
+
...(this.sandbox ? { sandbox: this.sandbox } : {}),
|
|
92
|
+
...this.configPayload(),
|
|
93
|
+
approvalPolicy: this.resolveApprovalPolicy(),
|
|
94
|
+
excludeTurns: true,
|
|
95
|
+
persistExtendedHistory: true,
|
|
96
|
+
});
|
|
97
|
+
this.loadedThreadId = this.threadId;
|
|
98
|
+
this.rememberResolvedModel(resumed);
|
|
99
|
+
}
|
|
100
|
+
if (!this.threadId) {
|
|
101
|
+
const started = await this.sendRequest('thread/start', {
|
|
102
|
+
cwd: this.cwd,
|
|
103
|
+
...(this.model ? { model: this.model } : {}),
|
|
104
|
+
...(this.sandbox ? { sandbox: this.sandbox } : {}),
|
|
105
|
+
...this.configPayload(),
|
|
106
|
+
approvalPolicy: this.resolveApprovalPolicy(),
|
|
107
|
+
experimentalRawEvents: false,
|
|
108
|
+
persistExtendedHistory: true,
|
|
109
|
+
});
|
|
110
|
+
const threadId = readString(started.thread, 'id');
|
|
111
|
+
if (!threadId)
|
|
112
|
+
throw new Error('Codex app-server did not return a thread id');
|
|
113
|
+
this.threadId = threadId;
|
|
114
|
+
this.loadedThreadId = threadId;
|
|
115
|
+
this.rememberResolvedModel(started);
|
|
116
|
+
onEvent({ type: 'thread.started', threadId });
|
|
117
|
+
}
|
|
118
|
+
const planModel = this.model ?? this.resolvedModel;
|
|
119
|
+
const turnPromise = new Promise((resolve, reject) => {
|
|
120
|
+
this.currentTurnResolve = resolve;
|
|
121
|
+
this.currentTurnReject = reject;
|
|
122
|
+
});
|
|
123
|
+
turnPromise.catch(() => { });
|
|
124
|
+
const turnStarted = await this.sendRequest('turn/start', {
|
|
125
|
+
threadId: this.threadId,
|
|
126
|
+
input: [
|
|
127
|
+
{ type: 'text', text: prompt, text_elements: [] },
|
|
128
|
+
...imagePaths.map((path) => ({ type: 'localImage', path })),
|
|
129
|
+
],
|
|
130
|
+
...(this.model ? { model: this.model } : {}),
|
|
131
|
+
...this.sandboxPolicyPayload(_extraAddDirs),
|
|
132
|
+
...(options.planMode
|
|
133
|
+
? {
|
|
134
|
+
collaborationMode: {
|
|
135
|
+
mode: 'plan',
|
|
136
|
+
settings: {
|
|
137
|
+
...(planModel ? { model: planModel } : {}),
|
|
138
|
+
reasoning_effort: null,
|
|
139
|
+
developer_instructions: null,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
});
|
|
145
|
+
const turn = turnStarted.turn;
|
|
146
|
+
if (this.currentTurnResolve) {
|
|
147
|
+
this.currentTurnId = readString(turn, 'id') ?? null;
|
|
148
|
+
onEvent({ type: 'turn.started' });
|
|
149
|
+
}
|
|
150
|
+
return await turnPromise;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
this.clearActiveTurn();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
resolveApprovalPolicy() {
|
|
158
|
+
if (this.bypassApprovalsAndSandbox || this.fullAuto)
|
|
159
|
+
return 'never';
|
|
160
|
+
return this.legacyApprovalPolicy;
|
|
161
|
+
}
|
|
162
|
+
configPayload() {
|
|
163
|
+
const config = {};
|
|
164
|
+
for (const raw of this.configOverrides) {
|
|
165
|
+
const separator = raw.indexOf('=');
|
|
166
|
+
if (separator <= 0)
|
|
167
|
+
continue;
|
|
168
|
+
const key = raw.slice(0, separator).trim();
|
|
169
|
+
if (!key)
|
|
170
|
+
continue;
|
|
171
|
+
config[key] = parseConfigValue(raw.slice(separator + 1));
|
|
172
|
+
}
|
|
173
|
+
return Object.keys(config).length > 0 ? { config } : {};
|
|
174
|
+
}
|
|
175
|
+
rememberResolvedModel(result) {
|
|
176
|
+
const thread = result.thread;
|
|
177
|
+
this.resolvedModel = readString(thread, 'model') ?? readString(result, 'model') ?? this.resolvedModel;
|
|
178
|
+
}
|
|
179
|
+
sandboxPolicyPayload(extraAddDirs) {
|
|
180
|
+
if (this.bypassApprovalsAndSandbox || this.sandbox === 'danger-full-access') {
|
|
181
|
+
return { sandboxPolicy: { type: 'dangerFullAccess' } };
|
|
182
|
+
}
|
|
183
|
+
if (this.sandbox === 'read-only') {
|
|
184
|
+
return { sandboxPolicy: { type: 'readOnly', networkAccess: true } };
|
|
185
|
+
}
|
|
186
|
+
if (this.sandbox !== 'workspace-write')
|
|
187
|
+
return {};
|
|
188
|
+
const writableRoots = this.buildWritableRoots(extraAddDirs);
|
|
189
|
+
return {
|
|
190
|
+
sandboxPolicy: {
|
|
191
|
+
type: 'workspaceWrite',
|
|
192
|
+
writableRoots,
|
|
193
|
+
networkAccess: true,
|
|
194
|
+
excludeTmpdirEnvVar: false,
|
|
195
|
+
excludeSlashTmp: false,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
buildWritableRoots(extraAddDirs) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const roots = [];
|
|
202
|
+
for (const raw of [this.cwd, ...this.addDirs, ...extraAddDirs]) {
|
|
203
|
+
const value = raw.trim();
|
|
204
|
+
if (!value || seen.has(value))
|
|
205
|
+
continue;
|
|
206
|
+
seen.add(value);
|
|
207
|
+
roots.push(value);
|
|
208
|
+
}
|
|
209
|
+
return roots;
|
|
210
|
+
}
|
|
211
|
+
async ensureStarted() {
|
|
212
|
+
if (this.child && this.initialized)
|
|
213
|
+
return;
|
|
214
|
+
const child = spawn(this.codexBin, ['app-server', '--listen', 'stdio://'], {
|
|
215
|
+
cwd: this.cwd,
|
|
216
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
this.child = child;
|
|
219
|
+
const stdout = createInterface({ input: child.stdout });
|
|
220
|
+
const stderr = createInterface({ input: child.stderr });
|
|
221
|
+
stdout.on('line', (line) => this.handleLine(line));
|
|
222
|
+
stderr.on('line', (line) => {
|
|
223
|
+
const trimmed = line.trim();
|
|
224
|
+
if (trimmed)
|
|
225
|
+
this.currentOnLog?.(trimmed);
|
|
226
|
+
});
|
|
227
|
+
child.on('close', (code) => {
|
|
228
|
+
const message = `Codex app-server exited${code === null ? '' : ` with code ${code}`}`;
|
|
229
|
+
this.initialized = false;
|
|
230
|
+
this.child = null;
|
|
231
|
+
for (const pending of this.pending.values()) {
|
|
232
|
+
pending.reject(new Error(message));
|
|
233
|
+
}
|
|
234
|
+
this.pending.clear();
|
|
235
|
+
if (this.currentTurnReject)
|
|
236
|
+
this.currentTurnReject(new Error(message));
|
|
237
|
+
this.clearActiveTurn();
|
|
238
|
+
});
|
|
239
|
+
child.on('error', (error) => {
|
|
240
|
+
if (this.currentTurnReject)
|
|
241
|
+
this.currentTurnReject(error);
|
|
242
|
+
});
|
|
243
|
+
await this.sendRequest('initialize', {
|
|
244
|
+
clientInfo: { name: 'canon-codex', version: '0.0.0' },
|
|
245
|
+
capabilities: { experimentalApi: true, supportsServerRequests: true },
|
|
246
|
+
});
|
|
247
|
+
this.initialized = true;
|
|
248
|
+
}
|
|
249
|
+
handleLine(line) {
|
|
250
|
+
const message = parseJson(line);
|
|
251
|
+
if (!message)
|
|
252
|
+
return;
|
|
253
|
+
if ('id' in message && ('result' in message || 'error' in message) && !('method' in message)) {
|
|
254
|
+
const id = Number(message.id);
|
|
255
|
+
const pending = this.pending.get(id);
|
|
256
|
+
if (!pending)
|
|
257
|
+
return;
|
|
258
|
+
this.pending.delete(id);
|
|
259
|
+
if (message.error) {
|
|
260
|
+
pending.reject(new Error(stringifyPreview(message.error)));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
pending.resolve(message.result);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if ('id' in message && typeof message.method === 'string') {
|
|
268
|
+
void this.handleServerRequest(message);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (typeof message.method === 'string') {
|
|
272
|
+
this.handleNotification(message.method, (message.params ?? {}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async handleServerRequest(request) {
|
|
276
|
+
try {
|
|
277
|
+
const result = this.currentRequestHandler
|
|
278
|
+
? await this.currentRequestHandler({
|
|
279
|
+
id: request.id,
|
|
280
|
+
method: request.method,
|
|
281
|
+
params: isRecord(request.params) ? request.params : {},
|
|
282
|
+
})
|
|
283
|
+
: defaultServerRequestResult(request.method);
|
|
284
|
+
this.write({ id: request.id, result });
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
this.write({
|
|
288
|
+
id: request.id,
|
|
289
|
+
error: {
|
|
290
|
+
code: -32000,
|
|
291
|
+
message: error instanceof Error ? error.message : String(error),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
handleNotification(method, params) {
|
|
297
|
+
if (method === 'turn/started') {
|
|
298
|
+
this.currentTurnId = readString(params.turn, 'id') ?? this.currentTurnId;
|
|
299
|
+
this.currentOnEvent?.({ type: 'turn.started' });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (method === 'thread/status/changed') {
|
|
303
|
+
const status = params.status;
|
|
304
|
+
if (status?.type === 'active' && Array.isArray(status.activeFlags)) {
|
|
305
|
+
const flags = status.activeFlags.map(String);
|
|
306
|
+
if (flags.includes('waitingOnApproval') || flags.includes('waitingOnUserInput')) {
|
|
307
|
+
this.currentOnEvent?.({ type: 'waiting', reason: flags.join(', ') });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (method === 'item/agentMessage/delta') {
|
|
313
|
+
const itemId = readString(params, 'itemId') ?? 'agent-message';
|
|
314
|
+
const delta = readString(params, 'delta') ?? '';
|
|
315
|
+
const next = `${this.messageTextByItem.get(itemId) ?? ''}${delta}`;
|
|
316
|
+
this.messageTextByItem.set(itemId, next);
|
|
317
|
+
this.currentFinalMessage = next.trim() || this.currentFinalMessage;
|
|
318
|
+
if (next.trim())
|
|
319
|
+
this.currentOnEvent?.({ type: 'message', text: next });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (method === 'turn/plan/updated') {
|
|
323
|
+
this.planText = renderPlan(params);
|
|
324
|
+
this.currentFinalMessage = this.planText || this.currentFinalMessage;
|
|
325
|
+
if (this.planText)
|
|
326
|
+
this.currentOnEvent?.({ type: 'plan.updated', text: this.planText });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (method === 'item/plan/delta') {
|
|
330
|
+
const delta = readString(params, 'delta') ?? '';
|
|
331
|
+
this.planText = `${this.planText}${delta}`;
|
|
332
|
+
this.currentFinalMessage = this.planText.trim() || this.currentFinalMessage;
|
|
333
|
+
if (this.planText.trim())
|
|
334
|
+
this.currentOnEvent?.({ type: 'plan.updated', text: this.planText });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (method === 'item/started') {
|
|
338
|
+
const item = params.item;
|
|
339
|
+
const summary = summarizeItem(item);
|
|
340
|
+
if (summary)
|
|
341
|
+
this.currentOnEvent?.({ type: 'command.started', command: summary });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (method === 'item/completed') {
|
|
345
|
+
const item = params.item;
|
|
346
|
+
if (item?.type === 'agentMessage') {
|
|
347
|
+
const text = readString(item, 'text');
|
|
348
|
+
if (text) {
|
|
349
|
+
this.currentFinalMessage = text;
|
|
350
|
+
this.currentOnEvent?.({ type: 'message', text });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else if (item?.type === 'commandExecution') {
|
|
354
|
+
this.currentOnEvent?.({
|
|
355
|
+
type: 'command.completed',
|
|
356
|
+
command: readString(item, 'command') ?? 'Command',
|
|
357
|
+
output: readString(item, 'aggregatedOutput') ?? '',
|
|
358
|
+
exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (method === 'turn/completed') {
|
|
364
|
+
const turn = params.turn;
|
|
365
|
+
const status = turn?.status;
|
|
366
|
+
if (isRecord(status) && status.type === 'failed') {
|
|
367
|
+
this.currentErrorText = stringifyPreview(turn?.error);
|
|
368
|
+
}
|
|
369
|
+
this.currentOnEvent?.({ type: 'turn.completed' });
|
|
370
|
+
this.resolveCurrentTurn();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (method === 'error') {
|
|
374
|
+
this.currentErrorText = stringifyPreview(params);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
resolveCurrentTurn() {
|
|
378
|
+
const result = {
|
|
379
|
+
threadId: this.threadId,
|
|
380
|
+
finalMessage: this.currentFinalMessage,
|
|
381
|
+
exitCode: this.currentErrorText ? 1 : 0,
|
|
382
|
+
interrupted: this.interrupted,
|
|
383
|
+
errorText: this.interrupted ? null : this.currentErrorText,
|
|
384
|
+
};
|
|
385
|
+
this.currentTurnResolve?.(result);
|
|
386
|
+
this.clearActiveTurn();
|
|
387
|
+
}
|
|
388
|
+
clearActiveTurn() {
|
|
389
|
+
this.currentTurnId = null;
|
|
390
|
+
this.currentOnEvent = null;
|
|
391
|
+
this.currentOnLog = null;
|
|
392
|
+
this.currentRequestHandler = null;
|
|
393
|
+
this.currentTurnResolve = null;
|
|
394
|
+
this.currentTurnReject = null;
|
|
395
|
+
this.currentFinalMessage = null;
|
|
396
|
+
this.currentErrorText = null;
|
|
397
|
+
this.messageTextByItem.clear();
|
|
398
|
+
this.planText = '';
|
|
399
|
+
}
|
|
400
|
+
sendRequest(method, params) {
|
|
401
|
+
const id = this.requestSeq++;
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
this.pending.set(id, { resolve, reject });
|
|
404
|
+
try {
|
|
405
|
+
this.write({ id, method, params });
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
this.pending.delete(id);
|
|
409
|
+
reject(error);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
write(message) {
|
|
414
|
+
if (!this.child)
|
|
415
|
+
throw new Error('Codex app-server is not running');
|
|
416
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function parseJson(line) {
|
|
420
|
+
try {
|
|
421
|
+
const parsed = JSON.parse(line);
|
|
422
|
+
return isRecord(parsed) ? parsed : null;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function isRecord(value) {
|
|
429
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
430
|
+
}
|
|
431
|
+
function readString(record, key) {
|
|
432
|
+
const value = record?.[key];
|
|
433
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
434
|
+
}
|
|
435
|
+
function stringifyPreview(value) {
|
|
436
|
+
if (typeof value === 'string')
|
|
437
|
+
return value;
|
|
438
|
+
try {
|
|
439
|
+
return JSON.stringify(value ?? {});
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return String(value);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function parseConfigValue(value) {
|
|
446
|
+
const trimmed = value.trim();
|
|
447
|
+
if (!trimmed)
|
|
448
|
+
return '';
|
|
449
|
+
if (trimmed === 'true')
|
|
450
|
+
return true;
|
|
451
|
+
if (trimmed === 'false')
|
|
452
|
+
return false;
|
|
453
|
+
if (trimmed === 'null')
|
|
454
|
+
return null;
|
|
455
|
+
const parsedNumber = Number(trimmed);
|
|
456
|
+
if (Number.isFinite(parsedNumber) && String(parsedNumber) === trimmed)
|
|
457
|
+
return parsedNumber;
|
|
458
|
+
try {
|
|
459
|
+
return JSON.parse(trimmed);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return value;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function summarizeItem(item) {
|
|
466
|
+
if (!item)
|
|
467
|
+
return null;
|
|
468
|
+
if (item.type === 'commandExecution')
|
|
469
|
+
return readString(item, 'command') ?? 'Running a command';
|
|
470
|
+
if (item.type === 'fileChange')
|
|
471
|
+
return 'Applying file changes';
|
|
472
|
+
if (item.type === 'mcpToolCall')
|
|
473
|
+
return `Calling ${readString(item, 'tool') ?? 'MCP tool'}`;
|
|
474
|
+
if (item.type === 'webSearch')
|
|
475
|
+
return 'Searching the web';
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
function renderPlan(params) {
|
|
479
|
+
const explanation = readString(params, 'explanation');
|
|
480
|
+
const plan = Array.isArray(params.plan) ? params.plan : [];
|
|
481
|
+
const lines = [
|
|
482
|
+
explanation ? `**Plan**\n${explanation}` : '**Plan**',
|
|
483
|
+
...plan.flatMap((entry, index) => {
|
|
484
|
+
if (!isRecord(entry))
|
|
485
|
+
return [];
|
|
486
|
+
const step = readString(entry, 'step');
|
|
487
|
+
if (!step)
|
|
488
|
+
return [];
|
|
489
|
+
const status = readString(entry, 'status') ?? 'pending';
|
|
490
|
+
const marker = status === 'completed' ? '[x]' : status === 'inProgress' ? '[~]' : '[ ]';
|
|
491
|
+
return [`${index + 1}. ${marker} ${step}`];
|
|
492
|
+
}),
|
|
493
|
+
];
|
|
494
|
+
return lines.join('\n');
|
|
495
|
+
}
|
|
496
|
+
function defaultServerRequestResult(method) {
|
|
497
|
+
if (method.includes('requestApproval'))
|
|
498
|
+
return { decision: 'decline' };
|
|
499
|
+
if (method === 'item/tool/requestUserInput')
|
|
500
|
+
return { answers: {} };
|
|
501
|
+
return {};
|
|
502
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setDefaultResultOrder } from 'node:dns';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { dirname } from 'node:path';
|
|
5
6
|
import { parseArgs } from 'node:util';
|
|
6
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
|
|
7
|
-
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata,
|
|
8
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
8
9
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
9
10
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
11
|
+
import { CodexAppServerAdapter } from './app-server-adapter.js';
|
|
12
|
+
import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
10
13
|
import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
11
14
|
import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
|
|
12
15
|
import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
@@ -64,6 +67,19 @@ let workspaceRoots = [];
|
|
|
64
67
|
let workspaceRootMetadata = [];
|
|
65
68
|
function buildCodexRuntimeDescriptor(input) {
|
|
66
69
|
const commands = [
|
|
70
|
+
...(input.supportsPlanMode
|
|
71
|
+
? [{
|
|
72
|
+
id: 'plan',
|
|
73
|
+
label: 'Plan first',
|
|
74
|
+
description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
|
|
75
|
+
aliases: ['plan'],
|
|
76
|
+
category: 'plan',
|
|
77
|
+
placements: ['composer_slash', 'command_palette'],
|
|
78
|
+
availability: ['always'],
|
|
79
|
+
trailingTextBehavior: 'send_as_prompt',
|
|
80
|
+
dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
|
|
81
|
+
}]
|
|
82
|
+
: []),
|
|
67
83
|
{
|
|
68
84
|
id: 'runtime-status',
|
|
69
85
|
label: 'Runtime status',
|
|
@@ -79,6 +95,8 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
79
95
|
...RUNTIME_NEW_SESSION_ACTION,
|
|
80
96
|
primitive: 'session.new',
|
|
81
97
|
},
|
|
98
|
+
RUNTIME_STOP_ACTION,
|
|
99
|
+
RUNTIME_STOP_AND_DROP_ACTION,
|
|
82
100
|
];
|
|
83
101
|
const descriptor = buildFirstPartyCodingRuntimeDescriptor({
|
|
84
102
|
clientType: 'codex',
|
|
@@ -91,11 +109,6 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
91
109
|
presentation: input.presentation,
|
|
92
110
|
streamingTextMode: 'snapshot',
|
|
93
111
|
commands,
|
|
94
|
-
actions: [
|
|
95
|
-
RUNTIME_STOP_ACTION,
|
|
96
|
-
RUNTIME_STOP_AND_DROP_ACTION,
|
|
97
|
-
RUNTIME_NEW_SESSION_ACTION,
|
|
98
|
-
],
|
|
99
112
|
});
|
|
100
113
|
if (input.models.length > 0) {
|
|
101
114
|
return descriptor;
|
|
@@ -119,18 +132,6 @@ function buildCodexModelOptions(model) {
|
|
|
119
132
|
? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
|
|
120
133
|
: [];
|
|
121
134
|
}
|
|
122
|
-
function normalizeRuntimeTurnState(value) {
|
|
123
|
-
const normalizedTurn = normalizeTurnState(value);
|
|
124
|
-
if (normalizedTurn) {
|
|
125
|
-
return {
|
|
126
|
-
state: normalizedTurn.state,
|
|
127
|
-
...(normalizedTurn.openedAt !== undefined ? { openedAt: normalizedTurn.openedAt } : {}),
|
|
128
|
-
...(normalizedTurn.updatedAt !== undefined ? { updatedAt: normalizedTurn.updatedAt } : {}),
|
|
129
|
-
...(normalizedTurn.turnUpdatedAt !== undefined ? { turnUpdatedAt: normalizedTurn.turnUpdatedAt } : {}),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
135
|
async function publishAgentRuntime(agentId, runtime) {
|
|
135
136
|
await publishHostAgentRuntime(agentId, 'codex', runtime);
|
|
136
137
|
}
|
|
@@ -249,6 +250,78 @@ function stringArgs(value) {
|
|
|
249
250
|
? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
250
251
|
: undefined;
|
|
251
252
|
}
|
|
253
|
+
function supportsCodexAppServer(codexBin) {
|
|
254
|
+
if (process.env.CANON_CODEX_TRANSPORT === 'exec')
|
|
255
|
+
return false;
|
|
256
|
+
if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
|
|
257
|
+
return true;
|
|
258
|
+
const result = spawnSync(codexBin, ['app-server', '--help'], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
261
|
+
});
|
|
262
|
+
return result.status === 0;
|
|
263
|
+
}
|
|
264
|
+
function parsePlanCommand(content) {
|
|
265
|
+
const trimmed = content.trimStart();
|
|
266
|
+
if (!trimmed.startsWith('/plan'))
|
|
267
|
+
return { planMode: false, content };
|
|
268
|
+
const rest = trimmed.replace(/^\/plan(?:\s+)?/i, '').trim();
|
|
269
|
+
return {
|
|
270
|
+
planMode: true,
|
|
271
|
+
content: rest || 'Please inspect the request and propose a plan before making changes.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function mapCodexQuestions(value) {
|
|
275
|
+
if (!Array.isArray(value))
|
|
276
|
+
return undefined;
|
|
277
|
+
const questions = value.slice(0, 12).flatMap((entry) => {
|
|
278
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
279
|
+
return [];
|
|
280
|
+
const record = entry;
|
|
281
|
+
const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 120) : null;
|
|
282
|
+
const question = typeof record.question === 'string' && record.question.trim()
|
|
283
|
+
? record.question.trim().slice(0, 1000)
|
|
284
|
+
: null;
|
|
285
|
+
if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
|
|
286
|
+
return [];
|
|
287
|
+
const header = typeof record.header === 'string' && record.header.trim()
|
|
288
|
+
? record.header.trim().slice(0, 120)
|
|
289
|
+
: undefined;
|
|
290
|
+
const rawOptions = Array.isArray(record.options) ? record.options : [];
|
|
291
|
+
const choices = rawOptions.slice(0, 12).flatMap((option) => {
|
|
292
|
+
if (!option || typeof option !== 'object' || Array.isArray(option))
|
|
293
|
+
return [];
|
|
294
|
+
const optionRecord = option;
|
|
295
|
+
const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
|
|
296
|
+
? optionRecord.label.trim().slice(0, 120)
|
|
297
|
+
: null;
|
|
298
|
+
if (!label)
|
|
299
|
+
return [];
|
|
300
|
+
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
301
|
+
? optionRecord.description.trim().slice(0, 300)
|
|
302
|
+
: undefined;
|
|
303
|
+
return [{ label, value: label, ...(description ? { description } : {}) }];
|
|
304
|
+
});
|
|
305
|
+
return [{
|
|
306
|
+
id,
|
|
307
|
+
question,
|
|
308
|
+
...(header ? { header } : {}),
|
|
309
|
+
...(choices.length > 0 ? { choices } : {}),
|
|
310
|
+
...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
|
|
311
|
+
...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
|
|
312
|
+
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
313
|
+
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
314
|
+
}];
|
|
315
|
+
});
|
|
316
|
+
return questions.length > 0 ? questions : undefined;
|
|
317
|
+
}
|
|
318
|
+
function isRecord(value) {
|
|
319
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
320
|
+
}
|
|
321
|
+
function readString(record, key) {
|
|
322
|
+
const value = record[key];
|
|
323
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
324
|
+
}
|
|
252
325
|
export async function main() {
|
|
253
326
|
setDefaultResultOrder('ipv4first');
|
|
254
327
|
const { values: args } = parseArgs({
|
|
@@ -290,12 +363,14 @@ export async function main() {
|
|
|
290
363
|
}
|
|
291
364
|
const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
|
|
292
365
|
const codexCliStatus = detectCodexCliVersion(codexBin);
|
|
366
|
+
const useAppServer = supportsCodexAppServer(codexBin);
|
|
293
367
|
if (codexCliStatus.version) {
|
|
294
368
|
console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
|
|
295
369
|
}
|
|
296
370
|
else {
|
|
297
371
|
console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
|
|
298
372
|
}
|
|
373
|
+
console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
|
|
299
374
|
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
|
|
300
375
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
301
376
|
const client = new CanonClient(apiKey, baseUrl);
|
|
@@ -418,14 +493,6 @@ export async function main() {
|
|
|
418
493
|
pendingMembershipChanges.delete(conversationId);
|
|
419
494
|
}
|
|
420
495
|
}
|
|
421
|
-
async function loadSenderRuntimeState(conversationId, senderId) {
|
|
422
|
-
try {
|
|
423
|
-
return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
496
|
async function loadHydratedInboundContext(input) {
|
|
430
497
|
const [conversation, page] = await Promise.all([
|
|
431
498
|
getConversationMeta(input.conversationId),
|
|
@@ -523,6 +590,61 @@ export async function main() {
|
|
|
523
590
|
function clearStreaming(conversationId) {
|
|
524
591
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
525
592
|
}
|
|
593
|
+
function writeCodexStreaming(session, text, status) {
|
|
594
|
+
if (text !== null) {
|
|
595
|
+
session.turnLiveText = text;
|
|
596
|
+
}
|
|
597
|
+
else if (status !== 'thinking' && session.turnLiveText === 'Thinking…') {
|
|
598
|
+
session.turnLiveText = '';
|
|
599
|
+
}
|
|
600
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
601
|
+
text: session.turnLiveText,
|
|
602
|
+
status,
|
|
603
|
+
messageId: session.currentTurnId ?? undefined,
|
|
604
|
+
turnId: session.currentTurnId,
|
|
605
|
+
blocks: session.turnBlocks,
|
|
606
|
+
}).catch(() => { });
|
|
607
|
+
}
|
|
608
|
+
function upsertTurnBlock(session, block) {
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
|
|
611
|
+
const existing = index >= 0 ? session.turnBlocks[index] : null;
|
|
612
|
+
const next = {
|
|
613
|
+
...(existing ?? {
|
|
614
|
+
sequence: session.turnBlocks.length + 1,
|
|
615
|
+
createdAt: now,
|
|
616
|
+
}),
|
|
617
|
+
...block,
|
|
618
|
+
turnId: session.currentTurnId ?? block.id,
|
|
619
|
+
updatedAt: now,
|
|
620
|
+
};
|
|
621
|
+
session.turnBlocks = index >= 0
|
|
622
|
+
? [
|
|
623
|
+
...session.turnBlocks.slice(0, index),
|
|
624
|
+
next,
|
|
625
|
+
...session.turnBlocks.slice(index + 1),
|
|
626
|
+
]
|
|
627
|
+
: [...session.turnBlocks, next];
|
|
628
|
+
}
|
|
629
|
+
function completeTurnBlock(session, id, summary) {
|
|
630
|
+
const existing = session.turnBlocks.find((block) => block.id === id);
|
|
631
|
+
if (!existing)
|
|
632
|
+
return;
|
|
633
|
+
upsertTurnBlock(session, {
|
|
634
|
+
id,
|
|
635
|
+
kind: existing.kind,
|
|
636
|
+
status: 'completed',
|
|
637
|
+
title: existing.title,
|
|
638
|
+
text: existing.text,
|
|
639
|
+
summary: summary ?? existing.summary,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function buildFinalTurnTrail(session) {
|
|
643
|
+
return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
|
|
644
|
+
...block,
|
|
645
|
+
turnId: session.currentTurnId ?? block.turnId,
|
|
646
|
+
})));
|
|
647
|
+
}
|
|
526
648
|
async function handoffFinalMessage(conversationId) {
|
|
527
649
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
528
650
|
clearStreaming(conversationId);
|
|
@@ -555,6 +677,9 @@ export async function main() {
|
|
|
555
677
|
return;
|
|
556
678
|
session.closed = true;
|
|
557
679
|
stopVisibleWorkSignal(session);
|
|
680
|
+
if ('close' in session.adapter && typeof session.adapter.close === 'function') {
|
|
681
|
+
session.adapter.close();
|
|
682
|
+
}
|
|
558
683
|
releaseConversationEnvironment(session.environment);
|
|
559
684
|
clearStreaming(conversationId);
|
|
560
685
|
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
@@ -638,11 +763,20 @@ export async function main() {
|
|
|
638
763
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
639
764
|
}
|
|
640
765
|
const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
766
|
+
const adapter = useAppServer
|
|
767
|
+
? new CodexAppServerAdapter({
|
|
768
|
+
cwd: sessionCwd,
|
|
769
|
+
threadId: storedThreadId,
|
|
770
|
+
codexBin,
|
|
771
|
+
model: policy.model ?? null,
|
|
772
|
+
sandbox: policy.sandbox,
|
|
773
|
+
approvalPolicy: policy.approvalPolicy,
|
|
774
|
+
addDirs: args['add-dir'] ?? [],
|
|
775
|
+
configOverrides: args.config ?? [],
|
|
776
|
+
fullAuto: policy.fullAuto,
|
|
777
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
778
|
+
})
|
|
779
|
+
: new CodexConversationAdapter({
|
|
646
780
|
cwd: sessionCwd,
|
|
647
781
|
threadId: storedThreadId,
|
|
648
782
|
codexBin,
|
|
@@ -654,7 +788,12 @@ export async function main() {
|
|
|
654
788
|
configOverrides: args.config ?? [],
|
|
655
789
|
fullAuto: policy.fullAuto,
|
|
656
790
|
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
657
|
-
})
|
|
791
|
+
});
|
|
792
|
+
const session = {
|
|
793
|
+
conversationId,
|
|
794
|
+
cwd: sessionCwd,
|
|
795
|
+
environment,
|
|
796
|
+
adapter,
|
|
658
797
|
queue: [],
|
|
659
798
|
running: false,
|
|
660
799
|
state: {
|
|
@@ -673,6 +812,8 @@ export async function main() {
|
|
|
673
812
|
lastActivity: Date.now(),
|
|
674
813
|
typingKeepaliveTimer: null,
|
|
675
814
|
closed: false,
|
|
815
|
+
turnLiveText: '',
|
|
816
|
+
turnBlocks: [],
|
|
676
817
|
};
|
|
677
818
|
sessions.set(conversationId, session);
|
|
678
819
|
await Promise.all([
|
|
@@ -697,8 +838,8 @@ export async function main() {
|
|
|
697
838
|
pendingSessionCreations.delete(conversationId);
|
|
698
839
|
}
|
|
699
840
|
}
|
|
700
|
-
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = []) {
|
|
701
|
-
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs };
|
|
841
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
|
|
842
|
+
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
|
|
702
843
|
if (toFront) {
|
|
703
844
|
session.queue.unshift(nextPrompt);
|
|
704
845
|
}
|
|
@@ -709,8 +850,145 @@ export async function main() {
|
|
|
709
850
|
writeTurn(session);
|
|
710
851
|
void runNextTurn(session);
|
|
711
852
|
}
|
|
853
|
+
async function waitForRuntimeInputResponse(input) {
|
|
854
|
+
while (Date.now() < input.expiresAt) {
|
|
855
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
856
|
+
conversationId: input.conversationId,
|
|
857
|
+
inputId: input.inputId,
|
|
858
|
+
}).catch(() => null);
|
|
859
|
+
if (response?.status === 'submitted') {
|
|
860
|
+
return { status: 'submitted', value: response.value, answers: response.answers };
|
|
861
|
+
}
|
|
862
|
+
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
863
|
+
return { status: response.status };
|
|
864
|
+
}
|
|
865
|
+
await sleep(1_000);
|
|
866
|
+
}
|
|
867
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
868
|
+
conversationId: input.conversationId,
|
|
869
|
+
inputId: input.inputId,
|
|
870
|
+
}).catch(() => null);
|
|
871
|
+
if (response?.status === 'submitted') {
|
|
872
|
+
return { status: 'submitted', value: response.value, answers: response.answers };
|
|
873
|
+
}
|
|
874
|
+
return { status: 'timeout' };
|
|
875
|
+
}
|
|
876
|
+
async function waitForRuntimeApprovalResponse(input) {
|
|
877
|
+
while (Date.now() < input.expiresAt) {
|
|
878
|
+
const response = await client.consumeRuntimeApprovalResponse({
|
|
879
|
+
conversationId: input.conversationId,
|
|
880
|
+
approvalId: input.approvalId,
|
|
881
|
+
}).catch(() => null);
|
|
882
|
+
if (response?.status === 'allow') {
|
|
883
|
+
return { decision: 'allow', sessionRule: response.sessionRule };
|
|
884
|
+
}
|
|
885
|
+
if (response?.status === 'deny' || response?.status === 'timeout') {
|
|
886
|
+
return { decision: 'deny' };
|
|
887
|
+
}
|
|
888
|
+
await sleep(1_000);
|
|
889
|
+
}
|
|
890
|
+
await client.consumeRuntimeApprovalResponse({
|
|
891
|
+
conversationId: input.conversationId,
|
|
892
|
+
approvalId: input.approvalId,
|
|
893
|
+
}).catch(() => null);
|
|
894
|
+
return { decision: 'deny' };
|
|
895
|
+
}
|
|
896
|
+
async function handleCodexServerRequest(session, request) {
|
|
897
|
+
const requestId = String(request.id);
|
|
898
|
+
const params = request.params;
|
|
899
|
+
const expiresAt = Date.now() + 30 * 60_000;
|
|
900
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
901
|
+
const paramsInput = isRecord(params.input) ? params.input : null;
|
|
902
|
+
const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
|
|
903
|
+
const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
|
|
904
|
+
const inputId = readString(params, 'itemId') ?? requestId;
|
|
905
|
+
await client.createRuntimeInputRequest({
|
|
906
|
+
conversationId: session.conversationId,
|
|
907
|
+
inputId,
|
|
908
|
+
kind: 'clarify',
|
|
909
|
+
expiresAt,
|
|
910
|
+
responseUserId: ownerId ?? undefined,
|
|
911
|
+
title: 'Codex needs input',
|
|
912
|
+
prompt: questions?.length
|
|
913
|
+
? 'Codex needs your input to continue.'
|
|
914
|
+
: 'Codex needs input.',
|
|
915
|
+
...(questions ? { questions } : {}),
|
|
916
|
+
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
917
|
+
native: {
|
|
918
|
+
runtime: 'codex',
|
|
919
|
+
method: request.method,
|
|
920
|
+
requestId,
|
|
921
|
+
turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
|
|
922
|
+
handles: {
|
|
923
|
+
itemId: readString(params, 'itemId') ?? '',
|
|
924
|
+
threadId: readString(params, 'threadId') ?? '',
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
turnId: session.currentTurnId ?? undefined,
|
|
928
|
+
});
|
|
929
|
+
const response = await waitForRuntimeInputResponse({
|
|
930
|
+
conversationId: session.conversationId,
|
|
931
|
+
inputId,
|
|
932
|
+
expiresAt,
|
|
933
|
+
});
|
|
934
|
+
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
935
|
+
}
|
|
936
|
+
const mappedApproval = mapCodexAppServerApprovalRequest({
|
|
937
|
+
method: request.method,
|
|
938
|
+
params,
|
|
939
|
+
});
|
|
940
|
+
if (mappedApproval) {
|
|
941
|
+
const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
|
|
942
|
+
await client.createRuntimeApprovalRequest({
|
|
943
|
+
conversationId: session.conversationId,
|
|
944
|
+
approvalId,
|
|
945
|
+
toolName: mappedApproval.toolName,
|
|
946
|
+
toolSummary: mappedApproval.toolSummary,
|
|
947
|
+
category: mappedApproval.category,
|
|
948
|
+
risk: mappedApproval.risk,
|
|
949
|
+
riskLevel: mappedApproval.riskLevel,
|
|
950
|
+
native: {
|
|
951
|
+
...mappedApproval.native,
|
|
952
|
+
requestId,
|
|
953
|
+
method: request.method,
|
|
954
|
+
},
|
|
955
|
+
details: mappedApproval.details,
|
|
956
|
+
responseUserId: ownerId ?? undefined,
|
|
957
|
+
allowSessionRule: true,
|
|
958
|
+
expiresAt,
|
|
959
|
+
turnId: session.currentTurnId ?? undefined,
|
|
960
|
+
});
|
|
961
|
+
const response = await waitForRuntimeApprovalResponse({
|
|
962
|
+
conversationId: session.conversationId,
|
|
963
|
+
approvalId,
|
|
964
|
+
expiresAt,
|
|
965
|
+
});
|
|
966
|
+
if (request.method === 'item/permissions/requestApproval') {
|
|
967
|
+
return response.decision === 'allow'
|
|
968
|
+
? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
|
|
969
|
+
: { permissions: {}, scope: 'turn' };
|
|
970
|
+
}
|
|
971
|
+
return mapCanonApprovalResultToCodexDecision({
|
|
972
|
+
decision: response.decision,
|
|
973
|
+
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
return {};
|
|
977
|
+
}
|
|
712
978
|
async function enqueueInboundMessage(input) {
|
|
713
979
|
knownConversationIds.add(input.conversationId);
|
|
980
|
+
if (isRecord(input.message.metadata)
|
|
981
|
+
&& input.message.metadata.type === 'plan_approval_reply'
|
|
982
|
+
&& typeof input.message.metadata.decision === 'string') {
|
|
983
|
+
const session = await getOrCreateSession(input.conversationId);
|
|
984
|
+
const feedback = readString(input.message.metadata, 'feedback');
|
|
985
|
+
const decision = input.message.metadata.decision;
|
|
986
|
+
const prompt = decision === 'approve'
|
|
987
|
+
? 'The plan was approved. Implement the approved plan now.'
|
|
988
|
+
: `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
|
|
989
|
+
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
714
992
|
let materialized = [];
|
|
715
993
|
if (input.message.id) {
|
|
716
994
|
try {
|
|
@@ -723,7 +1001,11 @@ export async function main() {
|
|
|
723
1001
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
724
1002
|
}
|
|
725
1003
|
}
|
|
726
|
-
const
|
|
1004
|
+
const renderedContent = renderInboundContent(input.message, materialized);
|
|
1005
|
+
const planCommand = useAppServer
|
|
1006
|
+
? parsePlanCommand(renderedContent)
|
|
1007
|
+
: { planMode: false, content: renderedContent };
|
|
1008
|
+
const content = planCommand.content;
|
|
727
1009
|
const hydrated = await loadHydratedInboundContext({
|
|
728
1010
|
conversationId: input.conversationId,
|
|
729
1011
|
message: input.message,
|
|
@@ -773,7 +1055,6 @@ export async function main() {
|
|
|
773
1055
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
774
1056
|
metadata: {
|
|
775
1057
|
turnSemantics: 'turn_complete',
|
|
776
|
-
turnComplete: true,
|
|
777
1058
|
replyBehavior: 'suppress_auto_reply',
|
|
778
1059
|
},
|
|
779
1060
|
}).catch(() => { });
|
|
@@ -789,14 +1070,14 @@ export async function main() {
|
|
|
789
1070
|
replyContext,
|
|
790
1071
|
});
|
|
791
1072
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
792
|
-
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
|
|
1073
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
793
1074
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
794
1075
|
await session.adapter.interrupt().catch(() => { });
|
|
795
1076
|
clearStreaming(input.conversationId);
|
|
796
1077
|
client.setTyping(input.conversationId, false).catch(() => { });
|
|
797
1078
|
return;
|
|
798
1079
|
}
|
|
799
|
-
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
|
|
1080
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
800
1081
|
}
|
|
801
1082
|
async function runNextTurn(session) {
|
|
802
1083
|
if (session.running || session.closed)
|
|
@@ -808,6 +1089,8 @@ export async function main() {
|
|
|
808
1089
|
session.state.lastError = undefined;
|
|
809
1090
|
session.state.state = 'running';
|
|
810
1091
|
session.currentTurnId = randomUUID();
|
|
1092
|
+
session.turnLiveText = '';
|
|
1093
|
+
session.turnBlocks = [];
|
|
811
1094
|
session.currentTurnOpenedAt = Date.now();
|
|
812
1095
|
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
813
1096
|
session.lastAcceptedIntent = nextTurn.intent;
|
|
@@ -817,10 +1100,7 @@ export async function main() {
|
|
|
817
1100
|
writeState(session);
|
|
818
1101
|
writeTurn(session);
|
|
819
1102
|
startVisibleWorkSignal(session);
|
|
820
|
-
|
|
821
|
-
text: 'Thinking…',
|
|
822
|
-
status: 'thinking',
|
|
823
|
-
}).catch(() => { });
|
|
1103
|
+
writeCodexStreaming(session, 'Thinking…', 'thinking');
|
|
824
1104
|
try {
|
|
825
1105
|
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
826
1106
|
if (modelGuard) {
|
|
@@ -844,10 +1124,31 @@ export async function main() {
|
|
|
844
1124
|
writeTurn(session);
|
|
845
1125
|
stopVisibleWorkSignal(session);
|
|
846
1126
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
847
|
-
|
|
1127
|
+
writeCodexStreaming(session, event.text, 'streaming');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (event.type === 'plan.updated') {
|
|
1131
|
+
session.turnState = 'streaming';
|
|
1132
|
+
markTurnProgress(session);
|
|
1133
|
+
writeTurn(session);
|
|
1134
|
+
stopVisibleWorkSignal(session);
|
|
1135
|
+
client.setTyping(session.conversationId, false).catch(() => { });
|
|
1136
|
+
upsertTurnBlock(session, {
|
|
1137
|
+
id: `plan:${session.currentTurnId}`,
|
|
1138
|
+
kind: 'plan',
|
|
1139
|
+
status: 'running',
|
|
1140
|
+
title: 'Plan',
|
|
848
1141
|
text: event.text,
|
|
849
|
-
|
|
850
|
-
|
|
1142
|
+
});
|
|
1143
|
+
writeCodexStreaming(session, event.text, 'streaming');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (event.type === 'waiting') {
|
|
1147
|
+
session.turnState = 'waiting_input';
|
|
1148
|
+
markTurnProgress(session);
|
|
1149
|
+
writeTurn(session);
|
|
1150
|
+
stopVisibleWorkSignal(session);
|
|
1151
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
851
1152
|
return;
|
|
852
1153
|
}
|
|
853
1154
|
if (event.type === 'command.started') {
|
|
@@ -855,18 +1156,24 @@ export async function main() {
|
|
|
855
1156
|
markTurnProgress(session);
|
|
856
1157
|
writeTurn(session);
|
|
857
1158
|
startVisibleWorkSignal(session);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1159
|
+
upsertTurnBlock(session, {
|
|
1160
|
+
id: `command:${session.currentTurnId}`,
|
|
1161
|
+
kind: 'tool',
|
|
1162
|
+
status: 'running',
|
|
1163
|
+
title: summarizeCommand(event.command),
|
|
1164
|
+
summary: 'Command running',
|
|
1165
|
+
});
|
|
1166
|
+
writeCodexStreaming(session, null, 'tool');
|
|
862
1167
|
return;
|
|
863
1168
|
}
|
|
864
1169
|
if (event.type === 'command.completed') {
|
|
1170
|
+
completeTurnBlock(session, `command:${session.currentTurnId}`, 'Command completed');
|
|
865
1171
|
if (session.turnState === 'tool') {
|
|
866
1172
|
session.turnState = 'thinking';
|
|
867
1173
|
markTurnProgress(session);
|
|
868
1174
|
writeTurn(session);
|
|
869
1175
|
startVisibleWorkSignal(session);
|
|
1176
|
+
writeCodexStreaming(session, null, 'thinking');
|
|
870
1177
|
}
|
|
871
1178
|
return;
|
|
872
1179
|
}
|
|
@@ -881,7 +1188,10 @@ export async function main() {
|
|
|
881
1188
|
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
882
1189
|
session.adapter.clearThreadId();
|
|
883
1190
|
};
|
|
884
|
-
const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs
|
|
1191
|
+
const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1192
|
+
planMode: nextTurn.planMode,
|
|
1193
|
+
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1194
|
+
});
|
|
885
1195
|
let result = await runTurnOnce();
|
|
886
1196
|
if (!result.interrupted
|
|
887
1197
|
&& !result.finalMessage
|
|
@@ -895,10 +1205,28 @@ export async function main() {
|
|
|
895
1205
|
if (result.threadId && !session.resetRequested) {
|
|
896
1206
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
897
1207
|
}
|
|
898
|
-
if (!result.interrupted && result.finalMessage) {
|
|
1208
|
+
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1209
|
+
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1210
|
+
responseUserId: ownerId ?? undefined,
|
|
1211
|
+
title: 'Codex Plan',
|
|
1212
|
+
body: result.finalMessage,
|
|
1213
|
+
});
|
|
1214
|
+
await client.sendMessage(session.conversationId, planApproval.text, {
|
|
1215
|
+
metadata: {
|
|
1216
|
+
...planApproval.metadata,
|
|
1217
|
+
turnId: session.currentTurnId,
|
|
1218
|
+
turnSemantics: 'control',
|
|
1219
|
+
replyBehavior: 'suppress_auto_reply',
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
await handoffFinalMessage(session.conversationId);
|
|
1223
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
1224
|
+
}
|
|
1225
|
+
else if (!result.interrupted && result.finalMessage) {
|
|
899
1226
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
900
1227
|
clearStoredThread();
|
|
901
1228
|
}
|
|
1229
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
902
1230
|
await client.sendMessage(session.conversationId, result.finalMessage, {
|
|
903
1231
|
...(session.activeSelfContextId
|
|
904
1232
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -906,8 +1234,8 @@ export async function main() {
|
|
|
906
1234
|
metadata: {
|
|
907
1235
|
turnId: session.currentTurnId,
|
|
908
1236
|
turnSemantics: 'turn_complete',
|
|
909
|
-
turnComplete: true,
|
|
910
1237
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1238
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
911
1239
|
},
|
|
912
1240
|
});
|
|
913
1241
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -920,6 +1248,7 @@ export async function main() {
|
|
|
920
1248
|
if (result.errorText) {
|
|
921
1249
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
922
1250
|
}
|
|
1251
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
923
1252
|
await client.sendMessage(session.conversationId, userVisibleError, {
|
|
924
1253
|
...(session.activeSelfContextId
|
|
925
1254
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -927,8 +1256,8 @@ export async function main() {
|
|
|
927
1256
|
metadata: {
|
|
928
1257
|
turnId: session.currentTurnId,
|
|
929
1258
|
turnSemantics: 'turn_complete',
|
|
930
|
-
turnComplete: true,
|
|
931
1259
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1260
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
932
1261
|
},
|
|
933
1262
|
});
|
|
934
1263
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -958,7 +1287,6 @@ export async function main() {
|
|
|
958
1287
|
metadata: {
|
|
959
1288
|
turnId: session.currentTurnId,
|
|
960
1289
|
turnSemantics: 'turn_complete',
|
|
961
|
-
turnComplete: true,
|
|
962
1290
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
963
1291
|
},
|
|
964
1292
|
}).catch(() => { });
|
|
@@ -1018,6 +1346,7 @@ export async function main() {
|
|
|
1018
1346
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1019
1347
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1020
1348
|
presentation: runtimePresentation,
|
|
1349
|
+
supportsPlanMode: useAppServer,
|
|
1021
1350
|
}),
|
|
1022
1351
|
};
|
|
1023
1352
|
async function baselineControlSignal(conversationId) {
|
|
@@ -1102,12 +1431,14 @@ export async function main() {
|
|
|
1102
1431
|
{
|
|
1103
1432
|
id: 'transport',
|
|
1104
1433
|
label: 'Transport',
|
|
1105
|
-
value: 'exec --json',
|
|
1434
|
+
value: useAppServer ? 'app-server' : 'exec --json',
|
|
1106
1435
|
},
|
|
1107
1436
|
{
|
|
1108
1437
|
id: 'streaming',
|
|
1109
1438
|
label: 'Live output',
|
|
1110
|
-
value:
|
|
1439
|
+
value: useAppServer
|
|
1440
|
+
? 'Plans, questions, approvals, tools, and message deltas'
|
|
1441
|
+
: 'Thinking, tools, and completed-message previews',
|
|
1111
1442
|
},
|
|
1112
1443
|
{
|
|
1113
1444
|
id: 'codex-cli',
|
|
@@ -1118,8 +1449,8 @@ export async function main() {
|
|
|
1118
1449
|
{
|
|
1119
1450
|
id: 'nativeActions',
|
|
1120
1451
|
label: 'Native actions',
|
|
1121
|
-
value: 'Limited until app-server transport',
|
|
1122
|
-
tone: 'warning',
|
|
1452
|
+
value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
|
|
1453
|
+
...(useAppServer ? {} : { tone: 'warning' }),
|
|
1123
1454
|
},
|
|
1124
1455
|
],
|
|
1125
1456
|
execution: {
|
|
@@ -1133,8 +1464,9 @@ export async function main() {
|
|
|
1133
1464
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1134
1465
|
},
|
|
1135
1466
|
notes: [
|
|
1136
|
-
|
|
1137
|
-
|
|
1467
|
+
useAppServer
|
|
1468
|
+
? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
|
|
1469
|
+
: 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not native plan questions or structured approvals.',
|
|
1138
1470
|
],
|
|
1139
1471
|
};
|
|
1140
1472
|
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
@@ -1209,6 +1541,7 @@ export async function main() {
|
|
|
1209
1541
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1210
1542
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1211
1543
|
presentation: runtimePresentation,
|
|
1544
|
+
supportsPlanMode: useAppServer,
|
|
1212
1545
|
}),
|
|
1213
1546
|
};
|
|
1214
1547
|
}
|
|
@@ -1229,6 +1562,7 @@ export async function main() {
|
|
|
1229
1562
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1230
1563
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1231
1564
|
presentation: runtimePresentation,
|
|
1565
|
+
supportsPlanMode: useAppServer,
|
|
1232
1566
|
}),
|
|
1233
1567
|
};
|
|
1234
1568
|
}
|
|
@@ -1258,13 +1592,9 @@ export async function main() {
|
|
|
1258
1592
|
? inboundMessages.slice(cursorIndex + 1)
|
|
1259
1593
|
: inboundMessages.slice(-1);
|
|
1260
1594
|
for (const latestMessage of messagesToRecover) {
|
|
1261
|
-
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
1262
|
-
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
1263
|
-
: null;
|
|
1264
1595
|
const triggerDecision = shouldTriggerAgentTurn({
|
|
1265
1596
|
senderType: latestMessage.senderType ?? 'human',
|
|
1266
1597
|
metadata: latestMessage.metadata,
|
|
1267
|
-
senderTurnState,
|
|
1268
1598
|
});
|
|
1269
1599
|
if (!triggerDecision.allow) {
|
|
1270
1600
|
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"prepack": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@canonmsg/agent-sdk": "^
|
|
33
|
-
"@canonmsg/core": "^0.
|
|
32
|
+
"@canonmsg/agent-sdk": "^2.0.0",
|
|
33
|
+
"@canonmsg/core": "^1.0.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=18.0.0"
|