@makaio/client-codex 1.0.0-dev-1779051654000
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/LICENSE +21 -0
- package/README.md +103 -0
- package/descriptor.json +21 -0
- package/dist/codex-client-session-service-DD2LkxP1.mjs +1551 -0
- package/dist/definition.d.ts +90 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +4 -0
- package/dist/package.d.ts +18 -0
- package/dist/package.d.ts.map +1 -0
- package/dist/runtime/client-settings.d.ts +149 -0
- package/dist/runtime/client-settings.d.ts.map +1 -0
- package/dist/runtime/codex-client-session-service.d.ts +148 -0
- package/dist/runtime/codex-client-session-service.d.ts.map +1 -0
- package/dist/runtime/config-prime-handler.d.ts +37 -0
- package/dist/runtime/config-prime-handler.d.ts.map +1 -0
- package/dist/runtime/hook-normalizer.d.ts +79 -0
- package/dist/runtime/hook-normalizer.d.ts.map +1 -0
- package/dist/runtime/namespace.d.ts +193 -0
- package/dist/runtime/namespace.d.ts.map +1 -0
- package/dist/runtime/package.d.ts +22 -0
- package/dist/runtime/package.d.ts.map +1 -0
- package/dist/runtime/package.mjs +33 -0
- package/dist/runtime/schemas.d.ts +26 -0
- package/dist/runtime/schemas.d.ts.map +1 -0
- package/dist/runtime/session-config-handler.d.ts +40 -0
- package/dist/runtime/session-config-handler.d.ts.map +1 -0
- package/dist/runtime/settings-paths.d.ts +43 -0
- package/dist/runtime/settings-paths.d.ts.map +1 -0
- package/dist/runtime/wiring.d.ts +88 -0
- package/dist/runtime/wiring.d.ts.map +1 -0
- package/dist/schemas/config.d.ts +306 -0
- package/dist/schemas/config.d.ts.map +1 -0
- package/dist/schemas/index.d.ts +9 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/wiring.d.ts +108 -0
- package/dist/schemas/wiring.d.ts.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +8 -0
- package/dist/src-DkIyzWF3.mjs +19 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
import { codexCapabilityMap, createClientDefinition } from "@makaio/framework/contracts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AbsolutePathSchema, BinaryNotFoundError, ClientSubjects, ClientWiringApplyResponseSchema, ClientWiringListResponseSchema, ClientWiringRemoveResponseSchema, assertAbsoluteProjectDir, atomicModifyFile, buildHookCommand, createClientNamespace, deriveSessionEventDescriptors, pickNonEmptyString } from "@makaio/framework/clients";
|
|
4
|
+
import { MakaioBus, RequestError } from "@makaio/framework/bus";
|
|
5
|
+
import { BaseService } from "@makaio/framework/service-base";
|
|
6
|
+
import * as fs$1 from "node:fs/promises";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import * as path$1 from "node:path";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { ClientConfigPrimeSchema, SessionConfigSetupRequestSchema, SessionConfigSetupResponseSchema } from "@makaio/framework/contracts/client";
|
|
13
|
+
|
|
14
|
+
//#region src/definition.ts
|
|
15
|
+
/**
|
|
16
|
+
* Client definition for the OpenAI Codex CLI.
|
|
17
|
+
*
|
|
18
|
+
* Codex is a first-party agentic coding assistant binary (`codex`) that
|
|
19
|
+
* Makaio harnesses via the codex-app-server adapter. Capability annotations
|
|
20
|
+
* are derived from `codexCapabilityMap` in `@makaio/contracts` to keep
|
|
21
|
+
* capability taxonomy in a single canonical location.
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Static client definition for `@makaio/client-codex`.
|
|
26
|
+
*
|
|
27
|
+
* Declares the two native tools the `codex` binary exposes (`bash` and
|
|
28
|
+
* `patch`) and the recommended default approval policy for new harnesses
|
|
29
|
+
* targeting this client.
|
|
30
|
+
*/
|
|
31
|
+
const clientDefinition = createClientDefinition({
|
|
32
|
+
id: "codex",
|
|
33
|
+
name: "Codex",
|
|
34
|
+
version: "0.1.0",
|
|
35
|
+
description: "OpenAI Codex CLI — an agentic coding assistant",
|
|
36
|
+
binary: {
|
|
37
|
+
name: "codex",
|
|
38
|
+
supportedVersions: "0.130.0"
|
|
39
|
+
},
|
|
40
|
+
managedInstall: {
|
|
41
|
+
type: "npm",
|
|
42
|
+
package: "@openai/codex",
|
|
43
|
+
version: "0.130.0"
|
|
44
|
+
},
|
|
45
|
+
versionCommand: {
|
|
46
|
+
executable: {
|
|
47
|
+
default: "node_modules/.bin/codex",
|
|
48
|
+
win32: "node_modules/.bin/codex.cmd"
|
|
49
|
+
},
|
|
50
|
+
args: ["--version"]
|
|
51
|
+
},
|
|
52
|
+
configIsolation: {
|
|
53
|
+
envVar: "CODEX_HOME",
|
|
54
|
+
defaultPath: "~/.codex"
|
|
55
|
+
},
|
|
56
|
+
nativeTools: [{
|
|
57
|
+
name: "bash",
|
|
58
|
+
friendlyName: "Terminal",
|
|
59
|
+
description: "Execute shell commands in the Codex sandbox",
|
|
60
|
+
category: "System",
|
|
61
|
+
capabilities: (codexCapabilityMap.bash ?? []).map((tag) => ({ tag }))
|
|
62
|
+
}, {
|
|
63
|
+
name: "patch",
|
|
64
|
+
friendlyName: "Patch File",
|
|
65
|
+
description: "Apply unified diff patches to files",
|
|
66
|
+
category: "Files",
|
|
67
|
+
capabilities: (codexCapabilityMap.patch ?? []).map((tag) => ({ tag }))
|
|
68
|
+
}],
|
|
69
|
+
defaultApprovalPolicy: "full-access",
|
|
70
|
+
defaultProviderId: "openai-codex",
|
|
71
|
+
runtimeCapabilities: {
|
|
72
|
+
supportsHooks: true,
|
|
73
|
+
supportsStatusline: false,
|
|
74
|
+
supportsSupervisorLaunch: true,
|
|
75
|
+
supportsManagedBinary: true,
|
|
76
|
+
hookEvents: [
|
|
77
|
+
{
|
|
78
|
+
name: "SessionStart",
|
|
79
|
+
frameworkSubject: "client.session.started"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "UserPromptSubmit",
|
|
83
|
+
frameworkSubject: "client.session.userPrompt.submitted"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "PreToolUse",
|
|
87
|
+
frameworkSubject: "client.session.tool.pre"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "PostToolUse",
|
|
91
|
+
frameworkSubject: "client.session.tool.post"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "Stop",
|
|
95
|
+
frameworkSubject: "client.session.turn.completed"
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/schemas/config.ts
|
|
103
|
+
/**
|
|
104
|
+
* Codex config management schemas.
|
|
105
|
+
*
|
|
106
|
+
* Defines request/response schema pairs for the `config.hooks.*` subjects in
|
|
107
|
+
* the Codex client namespace. Public request and response payloads expose a
|
|
108
|
+
* flat command-hook view, while `CodexNativeHooksFileSchema` models Codex's
|
|
109
|
+
* nested on-disk `hooks.json` structure for lossless runtime I/O.
|
|
110
|
+
*
|
|
111
|
+
* **Subjects:**
|
|
112
|
+
* - `config.hooks.list` — list effective hooks for a project directory
|
|
113
|
+
* - `config.hooks.add` — add a new hook entry to a scope's config
|
|
114
|
+
* - `config.hooks.remove` — remove hook entries matching a command pattern
|
|
115
|
+
* @packageDocumentation
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* Scope enum for Codex hook configuration.
|
|
119
|
+
*
|
|
120
|
+
* - `global` — applies to all Codex sessions regardless of project directory
|
|
121
|
+
* - `project` — scoped to a specific project directory
|
|
122
|
+
*/
|
|
123
|
+
const CodexScopeSchema = z.enum(["global", "project"]);
|
|
124
|
+
/**
|
|
125
|
+
* A single command hook entry in the public config-management API.
|
|
126
|
+
*
|
|
127
|
+
* Codex stores command hooks under `hooks[event][].hooks[]` on disk. The bus
|
|
128
|
+
* API flattens that native shape into `{ event, matcher?, command, timeout? }`
|
|
129
|
+
* so callers can manage hooks without duplicating file-format details.
|
|
130
|
+
*/
|
|
131
|
+
const CodexHookEntrySchema = z.object({
|
|
132
|
+
/**
|
|
133
|
+
* The hook event name that triggers this hook (e.g. `SessionStart`,
|
|
134
|
+
* `PreToolUse`).
|
|
135
|
+
*/
|
|
136
|
+
event: z.string(),
|
|
137
|
+
/**
|
|
138
|
+
* Optional glob pattern to restrict which tool names trigger the hook.
|
|
139
|
+
* Absent means the hook applies to all tools for the given event.
|
|
140
|
+
*/
|
|
141
|
+
matcher: z.string().optional(),
|
|
142
|
+
/**
|
|
143
|
+
* Shell command to execute when the hook fires.
|
|
144
|
+
*/
|
|
145
|
+
command: z.string(),
|
|
146
|
+
/**
|
|
147
|
+
* Optional timeout in seconds for the hook command.
|
|
148
|
+
* Absent means no timeout override (the Codex CLI default applies).
|
|
149
|
+
*/
|
|
150
|
+
timeout: z.number().optional()
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* Native Codex command hook handler as stored inside a matcher group.
|
|
154
|
+
*/
|
|
155
|
+
const CodexNativeCommandHookSchema = z.object({
|
|
156
|
+
/** Codex hook handler type. */
|
|
157
|
+
type: z.literal("command"),
|
|
158
|
+
/** Shell command to execute when the hook fires. */
|
|
159
|
+
command: z.string(),
|
|
160
|
+
/** Optional UI status message shown while the hook runs. */
|
|
161
|
+
statusMessage: z.string().optional(),
|
|
162
|
+
/** Optional timeout in seconds. */
|
|
163
|
+
timeout: z.number().optional(),
|
|
164
|
+
/** Alternate timeout spelling accepted by Codex. */
|
|
165
|
+
timeoutSec: z.number().optional()
|
|
166
|
+
}).passthrough();
|
|
167
|
+
/**
|
|
168
|
+
* Native Codex matcher group as stored under a hook event key.
|
|
169
|
+
*/
|
|
170
|
+
const CodexNativeHookMatcherGroupSchema = z.object({
|
|
171
|
+
/**
|
|
172
|
+
* Optional regex matcher. `undefined`, `""`, and `"*"` are all handled by
|
|
173
|
+
* Codex as broad matches depending on event support.
|
|
174
|
+
*/
|
|
175
|
+
matcher: z.string().optional(),
|
|
176
|
+
/** Hook handlers to run when the matcher group applies. */
|
|
177
|
+
hooks: z.array(z.unknown())
|
|
178
|
+
}).passthrough();
|
|
179
|
+
/**
|
|
180
|
+
* Native Codex `hooks.json` file shape.
|
|
181
|
+
*
|
|
182
|
+
* Top-level and nested objects are passthrough so runtime writes preserve
|
|
183
|
+
* fields introduced by newer Codex versions instead of stripping them.
|
|
184
|
+
*/
|
|
185
|
+
const CodexNativeHooksFileSchema = z.object({ hooks: z.record(z.string(), z.array(CodexNativeHookMatcherGroupSchema)).optional() }).passthrough();
|
|
186
|
+
/**
|
|
187
|
+
* Per-scope hook configuration record returned by `config.hooks.list`.
|
|
188
|
+
*
|
|
189
|
+
* Describes all hooks registered at a single config scope together with
|
|
190
|
+
* the path to the backing config file and whether it can be written.
|
|
191
|
+
*/
|
|
192
|
+
const CodexScopeHookRecordSchema = z.object({
|
|
193
|
+
/**
|
|
194
|
+
* Config scope this record belongs to.
|
|
195
|
+
*/
|
|
196
|
+
scope: CodexScopeSchema,
|
|
197
|
+
/**
|
|
198
|
+
* Absolute path to the config file backing this scope.
|
|
199
|
+
*/
|
|
200
|
+
path: z.string(),
|
|
201
|
+
/**
|
|
202
|
+
* Whether the backing config file can be written by the current process.
|
|
203
|
+
*/
|
|
204
|
+
writable: z.boolean(),
|
|
205
|
+
/**
|
|
206
|
+
* All hooks registered at this scope.
|
|
207
|
+
*/
|
|
208
|
+
hooks: z.array(CodexHookEntrySchema)
|
|
209
|
+
});
|
|
210
|
+
/**
|
|
211
|
+
* Request schema for `config.hooks.list`.
|
|
212
|
+
*
|
|
213
|
+
* Returns the effective hook configuration for the given project directory,
|
|
214
|
+
* optionally filtered to a specific event name.
|
|
215
|
+
*/
|
|
216
|
+
const CodexConfigHooksListRequestSchema = z.object({
|
|
217
|
+
/**
|
|
218
|
+
* Optional absolute path to the project directory.
|
|
219
|
+
* When absent, only the global scope is included — project-scoped hooks
|
|
220
|
+
* are omitted from both `effective` and `perScope`.
|
|
221
|
+
*/
|
|
222
|
+
projectDir: AbsolutePathSchema.optional(),
|
|
223
|
+
/**
|
|
224
|
+
* Optional event name to filter hooks by (e.g. `PreToolUse`).
|
|
225
|
+
* When absent all event hooks are returned.
|
|
226
|
+
*/
|
|
227
|
+
eventName: z.string().optional()
|
|
228
|
+
});
|
|
229
|
+
/**
|
|
230
|
+
* Response schema for `config.hooks.list`.
|
|
231
|
+
*
|
|
232
|
+
* Returns the merged effective hook list alongside the per-scope breakdown so
|
|
233
|
+
* consumers can determine which config file contributes each hook.
|
|
234
|
+
*/
|
|
235
|
+
const CodexConfigHooksListResponseSchema = z.object({
|
|
236
|
+
/**
|
|
237
|
+
* Effective merged hook list after applying scope precedence rules.
|
|
238
|
+
* This is the set of hooks that actually fire for a Codex session
|
|
239
|
+
* targeting the given project directory.
|
|
240
|
+
*/
|
|
241
|
+
effective: z.array(CodexHookEntrySchema),
|
|
242
|
+
/**
|
|
243
|
+
* Per-scope breakdown of hook configuration, ordered from lowest precedence
|
|
244
|
+
* (global) to highest (project). Each entry includes the backing file path
|
|
245
|
+
* and writability flag.
|
|
246
|
+
*/
|
|
247
|
+
perScope: z.array(CodexScopeHookRecordSchema)
|
|
248
|
+
});
|
|
249
|
+
/**
|
|
250
|
+
* Request schema for `config.hooks.add`.
|
|
251
|
+
*
|
|
252
|
+
* Adds a new hook entry to the specified scope's configuration.
|
|
253
|
+
* The hook entry fields (`event`, `matcher`, `command`, `timeout`) are
|
|
254
|
+
* composed via {@link CodexHookEntrySchema} so any change to the hook shape
|
|
255
|
+
* propagates here automatically.
|
|
256
|
+
*/
|
|
257
|
+
const CodexConfigHooksAddRequestSchema = z.object({
|
|
258
|
+
/**
|
|
259
|
+
* Optional absolute path to the project directory.
|
|
260
|
+
* Required when `scope` is `project` so the correct project config file
|
|
261
|
+
* can be located.
|
|
262
|
+
*/
|
|
263
|
+
projectDir: AbsolutePathSchema.optional(),
|
|
264
|
+
/**
|
|
265
|
+
* Config scope to write the hook into.
|
|
266
|
+
*/
|
|
267
|
+
scope: CodexScopeSchema
|
|
268
|
+
}).merge(CodexHookEntrySchema).refine((data) => data.scope === "global" || data.projectDir !== void 0, {
|
|
269
|
+
message: "projectDir is required when scope is project",
|
|
270
|
+
path: ["projectDir"]
|
|
271
|
+
});
|
|
272
|
+
/**
|
|
273
|
+
* Response schema for `config.hooks.add`.
|
|
274
|
+
*/
|
|
275
|
+
const CodexConfigHooksAddResponseSchema = z.object({
|
|
276
|
+
/**
|
|
277
|
+
* Whether the hook entry was successfully written to the config file.
|
|
278
|
+
*/
|
|
279
|
+
added: z.boolean() });
|
|
280
|
+
/**
|
|
281
|
+
* Request schema for `config.hooks.remove`.
|
|
282
|
+
*
|
|
283
|
+
* Removes hook entries from the specified scope's configuration that match
|
|
284
|
+
* both the `event` and the command substring filter.
|
|
285
|
+
*/
|
|
286
|
+
const CodexConfigHooksRemoveRequestSchema = z.object({
|
|
287
|
+
/**
|
|
288
|
+
* Optional absolute path to the project directory.
|
|
289
|
+
* Required when `scope` is `project`.
|
|
290
|
+
*/
|
|
291
|
+
projectDir: AbsolutePathSchema.optional(),
|
|
292
|
+
/**
|
|
293
|
+
* Config scope from which to remove matching hooks.
|
|
294
|
+
*/
|
|
295
|
+
scope: CodexScopeSchema,
|
|
296
|
+
/**
|
|
297
|
+
* Event name to match against when selecting hooks for removal.
|
|
298
|
+
*/
|
|
299
|
+
event: z.string(),
|
|
300
|
+
/**
|
|
301
|
+
* Filter criteria selecting which hooks to remove.
|
|
302
|
+
* All hooks whose `command` field contains {@link commandContains} as a
|
|
303
|
+
* substring are removed.
|
|
304
|
+
*/
|
|
305
|
+
match: z.object({
|
|
306
|
+
/**
|
|
307
|
+
* Substring that must appear in the hook command for removal.
|
|
308
|
+
*/
|
|
309
|
+
commandContains: z.string() })
|
|
310
|
+
}).refine((data) => data.scope === "global" || data.projectDir !== void 0, {
|
|
311
|
+
message: "projectDir is required when scope is project",
|
|
312
|
+
path: ["projectDir"]
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* Response schema for `config.hooks.remove`.
|
|
316
|
+
*/
|
|
317
|
+
const CodexConfigHooksRemoveResponseSchema = z.object({
|
|
318
|
+
/**
|
|
319
|
+
* Number of hook entries removed from the config file.
|
|
320
|
+
*/
|
|
321
|
+
removed: z.number() });
|
|
322
|
+
/**
|
|
323
|
+
* Schema record for Codex config management subjects.
|
|
324
|
+
*
|
|
325
|
+
* Pass this record as the `additionalSchemas` argument to
|
|
326
|
+
* {@link createClientNamespace} to register these request/response subjects
|
|
327
|
+
* in the `client:codex.*` namespace.
|
|
328
|
+
*
|
|
329
|
+
* Keys use dotted notation matching the bus subject naming convention:
|
|
330
|
+
* - `config.hooks.list`
|
|
331
|
+
* - `config.hooks.add`
|
|
332
|
+
* - `config.hooks.remove`
|
|
333
|
+
* @example
|
|
334
|
+
* ```typescript
|
|
335
|
+
* import { createClientNamespace } from '@makaio/clients-core';
|
|
336
|
+
* import { CodexConfigSchemas } from './schemas/index.js';
|
|
337
|
+
*
|
|
338
|
+
* const { subjects } = createClientNamespace('codex', CodexConfigSchemas);
|
|
339
|
+
* // subjects.config.hooks.list → 'client:codex.config.hooks.list'
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
const CodexConfigSchemas = {
|
|
343
|
+
/**
|
|
344
|
+
* List effective hook configuration for a project directory.
|
|
345
|
+
*
|
|
346
|
+
* Merges global and project-scoped hooks and returns both the effective list
|
|
347
|
+
* and the per-scope breakdown.
|
|
348
|
+
*/
|
|
349
|
+
"config.hooks.list": {
|
|
350
|
+
request: CodexConfigHooksListRequestSchema,
|
|
351
|
+
response: CodexConfigHooksListResponseSchema
|
|
352
|
+
},
|
|
353
|
+
/**
|
|
354
|
+
* Add a new hook entry to a config scope.
|
|
355
|
+
*/
|
|
356
|
+
"config.hooks.add": {
|
|
357
|
+
request: CodexConfigHooksAddRequestSchema,
|
|
358
|
+
response: CodexConfigHooksAddResponseSchema
|
|
359
|
+
},
|
|
360
|
+
/**
|
|
361
|
+
* Remove hook entries matching an event and command substring from a scope.
|
|
362
|
+
*/
|
|
363
|
+
"config.hooks.remove": {
|
|
364
|
+
request: CodexConfigHooksRemoveRequestSchema,
|
|
365
|
+
response: CodexConfigHooksRemoveResponseSchema
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/schemas/wiring.ts
|
|
371
|
+
/**
|
|
372
|
+
* Schema record for Codex wiring management subjects.
|
|
373
|
+
*
|
|
374
|
+
* Pass this record (spread) as part of the `additionalSchemas` argument to
|
|
375
|
+
* {@link createClientNamespace} to register these request/response subjects
|
|
376
|
+
* in the `client:codex.*` namespace.
|
|
377
|
+
*
|
|
378
|
+
* Keys use dotted notation matching the bus subject naming convention:
|
|
379
|
+
* - `wiring.list`
|
|
380
|
+
* - `wiring.apply`
|
|
381
|
+
* - `wiring.remove`
|
|
382
|
+
* @example
|
|
383
|
+
* ```typescript
|
|
384
|
+
* import { createClientNamespace } from '@makaio/clients-core';
|
|
385
|
+
* import { CodexConfigSchemas } from './schemas/config.js';
|
|
386
|
+
* import { CodexWiringSchemas } from './schemas/wiring.js';
|
|
387
|
+
*
|
|
388
|
+
* const { subjects } = createClientNamespace('codex', {
|
|
389
|
+
* ...CodexConfigSchemas,
|
|
390
|
+
* ...CodexWiringSchemas,
|
|
391
|
+
* });
|
|
392
|
+
* // subjects.wiring.list → 'client:codex.wiring.list'
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
const CodexWiringSchemas = {
|
|
396
|
+
/**
|
|
397
|
+
* List all known wiring entries for the target scope, indicating which are
|
|
398
|
+
* currently installed in the Codex native config.
|
|
399
|
+
*
|
|
400
|
+
* When `projectDir` is absent, only the `global` scope entries are reported.
|
|
401
|
+
* Callers that need project-scope entries must supply the absolute path to
|
|
402
|
+
* the project root.
|
|
403
|
+
*/
|
|
404
|
+
"wiring.list": {
|
|
405
|
+
request: z.object({
|
|
406
|
+
/**
|
|
407
|
+
* Absolute path of the project directory used to locate project-scope
|
|
408
|
+
* config files. When absent, only the global scope is consulted.
|
|
409
|
+
*/
|
|
410
|
+
projectDir: AbsolutePathSchema.optional(),
|
|
411
|
+
/**
|
|
412
|
+
* Makaio shell command to use when building the expected command strings
|
|
413
|
+
* in the response entries.
|
|
414
|
+
*/
|
|
415
|
+
makaioCommand: z.string().min(1)
|
|
416
|
+
}),
|
|
417
|
+
response: ClientWiringListResponseSchema
|
|
418
|
+
},
|
|
419
|
+
/**
|
|
420
|
+
* Install all wiring entries into the specified scope.
|
|
421
|
+
*
|
|
422
|
+
* Entries already present are skipped (idempotent). The `makaioCommand`
|
|
423
|
+
* string is written verbatim as the shell command for hook entries.
|
|
424
|
+
*/
|
|
425
|
+
"wiring.apply": {
|
|
426
|
+
request: z.object({
|
|
427
|
+
/** Scope at which to install the wiring entries. */
|
|
428
|
+
scope: CodexScopeSchema,
|
|
429
|
+
/**
|
|
430
|
+
* Absolute path of the project directory. Required when `scope` is
|
|
431
|
+
* `'project'`; ignored for `'global'`.
|
|
432
|
+
*/
|
|
433
|
+
projectDir: AbsolutePathSchema.optional(),
|
|
434
|
+
/**
|
|
435
|
+
* Makaio shell command to write into the native config. Must be
|
|
436
|
+
* non-empty.
|
|
437
|
+
*/
|
|
438
|
+
makaioCommand: z.string().min(1)
|
|
439
|
+
}).refine((data) => data.scope === "global" || data.projectDir !== void 0, {
|
|
440
|
+
message: "projectDir is required when scope is project",
|
|
441
|
+
path: ["projectDir"]
|
|
442
|
+
}),
|
|
443
|
+
response: ClientWiringApplyResponseSchema
|
|
444
|
+
},
|
|
445
|
+
/**
|
|
446
|
+
* Uninstall all wiring entries from the specified scope.
|
|
447
|
+
*
|
|
448
|
+
* Entries that are not present are silently ignored (idempotent). The
|
|
449
|
+
* `removed` count in the response reflects only entries that were actually
|
|
450
|
+
* deleted from the config file.
|
|
451
|
+
*/
|
|
452
|
+
"wiring.remove": {
|
|
453
|
+
request: z.object({
|
|
454
|
+
/** Scope from which to remove wiring entries. */
|
|
455
|
+
scope: CodexScopeSchema,
|
|
456
|
+
/**
|
|
457
|
+
* Absolute path of the project directory. Required when `scope` is
|
|
458
|
+
* `'project'`; ignored for `'global'`.
|
|
459
|
+
*/
|
|
460
|
+
projectDir: AbsolutePathSchema.optional()
|
|
461
|
+
}).refine((data) => data.scope === "global" || data.projectDir !== void 0, {
|
|
462
|
+
message: "projectDir is required when scope is project",
|
|
463
|
+
path: ["projectDir"]
|
|
464
|
+
}),
|
|
465
|
+
response: ClientWiringRemoveResponseSchema
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/runtime/settings-paths.ts
|
|
471
|
+
/**
|
|
472
|
+
* Codex client settings path resolution.
|
|
473
|
+
*
|
|
474
|
+
* Provides a pure utility for computing the filesystem paths where Codex
|
|
475
|
+
* stores its `hooks.json` configuration files — one global path under the
|
|
476
|
+
* resolved Codex config directory and an optional project-scoped path.
|
|
477
|
+
*
|
|
478
|
+
* No filesystem I/O is performed; callers are responsible for reading,
|
|
479
|
+
* writing, or watching the returned paths.
|
|
480
|
+
* @packageDocumentation
|
|
481
|
+
*/
|
|
482
|
+
/** Resolved home directory, captured once at module load time. */
|
|
483
|
+
const HOME_DIR = os.homedir();
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the filesystem paths for Codex `hooks.json` configuration files.
|
|
486
|
+
*
|
|
487
|
+
* This is a pure function: it performs no I/O and has no side effects.
|
|
488
|
+
* The returned paths may or may not exist on disk.
|
|
489
|
+
* @param projectDir - Absolute path to the project root directory. When
|
|
490
|
+
* provided, {@link CodexSettingsPaths.projectHooks} is set to
|
|
491
|
+
* `{projectDir}/.codex/hooks.json`. When omitted, `projectHooks` is `null`.
|
|
492
|
+
* @param configDir - Optional Codex config root. When omitted, the global
|
|
493
|
+
* hooks path falls back to `~/.codex/hooks.json`.
|
|
494
|
+
* @returns Resolved paths for the global and optional project-scoped Codex
|
|
495
|
+
* hooks configuration files.
|
|
496
|
+
*/
|
|
497
|
+
function resolveCodexSettingsPaths(projectDir, configDir) {
|
|
498
|
+
const globalRoot = configDir ?? path.join(HOME_DIR, ".codex");
|
|
499
|
+
return {
|
|
500
|
+
globalHooks: path.join(globalRoot, "hooks.json"),
|
|
501
|
+
projectHooks: projectDir !== void 0 ? path.join(projectDir, ".codex", "hooks.json") : null
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/runtime/client-settings.ts
|
|
507
|
+
/**
|
|
508
|
+
* Codex client settings I/O.
|
|
509
|
+
*
|
|
510
|
+
* Provides filesystem read/write operations for Codex `hooks.json`
|
|
511
|
+
* configuration files. Handles both global and project-scoped config files
|
|
512
|
+
* with atomic writes and per-path write serialization to prevent concurrent
|
|
513
|
+
* modification.
|
|
514
|
+
* @packageDocumentation
|
|
515
|
+
*/
|
|
516
|
+
/** Empty native hooks file used as the baseline for missing files. */
|
|
517
|
+
const EMPTY_HOOKS_FILE = { hooks: {} };
|
|
518
|
+
/**
|
|
519
|
+
* Global per-path write mutex shared across all {@link CodexClientSettings}
|
|
520
|
+
* instances. Module-scoped so that concurrent requests routed to different
|
|
521
|
+
* service-handler instances still serialize writes to the same file.
|
|
522
|
+
*/
|
|
523
|
+
const writeMutex = /* @__PURE__ */ new Map();
|
|
524
|
+
/**
|
|
525
|
+
* Handles reading and writing Codex `hooks.json` configuration files.
|
|
526
|
+
*
|
|
527
|
+
* This is a plain composed component — not a `BaseService` — intended to be
|
|
528
|
+
* instantiated inside `CodexClientSessionService` or a similar host.
|
|
529
|
+
*
|
|
530
|
+
* ## Atomic writes
|
|
531
|
+
* All writes go through a temp-file + `fs.rename()` sequence so that readers
|
|
532
|
+
* never see a partially written file.
|
|
533
|
+
*
|
|
534
|
+
* ## Write serialization
|
|
535
|
+
* A module-scoped per-path mutex ensures that concurrent callers modifying
|
|
536
|
+
* the same file are serialized rather than racing, even across instances.
|
|
537
|
+
*/
|
|
538
|
+
var CodexClientSettings = class {
|
|
539
|
+
/**
|
|
540
|
+
* Optional path override used in tests. When `undefined`, paths are
|
|
541
|
+
* resolved dynamically via {@link resolveCodexSettingsPaths}.
|
|
542
|
+
*/
|
|
543
|
+
pathsOverride;
|
|
544
|
+
/** Optional managed Codex config root for global-scope hooks. */
|
|
545
|
+
configDir;
|
|
546
|
+
/**
|
|
547
|
+
* Creates a new `CodexClientSettings` instance.
|
|
548
|
+
* @param options - Optional path override or config-root options. Passing
|
|
549
|
+
* `{ globalHooks, projectHooks }` remains supported for existing tests.
|
|
550
|
+
*/
|
|
551
|
+
constructor(options) {
|
|
552
|
+
if (options !== void 0 && "globalHooks" in options) {
|
|
553
|
+
this.pathsOverride = options;
|
|
554
|
+
this.configDir = void 0;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.pathsOverride = options?.pathsOverride;
|
|
558
|
+
this.configDir = options?.configDir;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* List the effective hook configuration for a project directory.
|
|
562
|
+
*
|
|
563
|
+
* Reads both the global and (when available) project-scoped config files,
|
|
564
|
+
* concatenates their hooks into an effective list, and returns the per-scope
|
|
565
|
+
* breakdown alongside it.
|
|
566
|
+
* @param req - Request options. `projectDir` is the optional absolute path to
|
|
567
|
+
* the project root (when omitted only the global scope is read).
|
|
568
|
+
* `eventName` is an optional event name filter; when omitted all hooks are
|
|
569
|
+
* returned.
|
|
570
|
+
* @returns Effective merged hook list and per-scope breakdown.
|
|
571
|
+
*/
|
|
572
|
+
async listHooks(req) {
|
|
573
|
+
const paths = this.resolvePaths(req.projectDir);
|
|
574
|
+
const globalPromise = Promise.all([this.readHooksFile(paths.globalHooks), this.isWritable(paths.globalHooks)]);
|
|
575
|
+
const projectPath = paths.projectHooks;
|
|
576
|
+
const [globalHooks, globalWritable] = await globalPromise;
|
|
577
|
+
const perScope = [{
|
|
578
|
+
scope: "global",
|
|
579
|
+
path: paths.globalHooks,
|
|
580
|
+
writable: globalWritable,
|
|
581
|
+
hooks: globalHooks
|
|
582
|
+
}];
|
|
583
|
+
if (projectPath !== null) {
|
|
584
|
+
const [projectHooks, projectWritable] = await Promise.all([this.readHooksFile(projectPath), this.isWritable(projectPath)]);
|
|
585
|
+
perScope.push({
|
|
586
|
+
scope: "project",
|
|
587
|
+
path: projectPath,
|
|
588
|
+
writable: projectWritable,
|
|
589
|
+
hooks: projectHooks
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
let effective = perScope.flatMap((record) => record.hooks);
|
|
593
|
+
if (req.eventName !== void 0) effective = effective.filter((entry) => entry.event === req.eventName);
|
|
594
|
+
return {
|
|
595
|
+
effective,
|
|
596
|
+
perScope
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Add a new hook entry to the specified config scope.
|
|
601
|
+
*
|
|
602
|
+
* The operation is idempotent: if a hook with the same `event`, `command`,
|
|
603
|
+
* and `matcher` already exists in the target file, the file is left
|
|
604
|
+
* unchanged and `{ added: false }` is returned.
|
|
605
|
+
* @param req - Hook entry and targeting options. `scope` selects the config
|
|
606
|
+
* file; `projectDir` is required when `scope` is `'project'`. `event`,
|
|
607
|
+
* `command`, and optional `matcher` / `timeout` form the hook entry.
|
|
608
|
+
* @returns `{ added: true }` when the hook was appended, `{ added: false }`
|
|
609
|
+
* when an identical hook already exists.
|
|
610
|
+
*/
|
|
611
|
+
async addHook(req) {
|
|
612
|
+
const filePath = this.resolvePathForScope(req.scope, req.projectDir);
|
|
613
|
+
const { result } = await this.modifyHooksFile(filePath, (hooks) => {
|
|
614
|
+
const eventGroups = hooks.hooks?.[req.event] ?? [];
|
|
615
|
+
if (eventGroups.some((group) => {
|
|
616
|
+
if (group.matcher !== req.matcher) return false;
|
|
617
|
+
return group.hooks.some((handler) => {
|
|
618
|
+
const parseResult = CodexNativeCommandHookSchema.safeParse(handler);
|
|
619
|
+
return parseResult.success && parseResult.data.command === req.command;
|
|
620
|
+
});
|
|
621
|
+
})) return {
|
|
622
|
+
hooks,
|
|
623
|
+
result: { added: false },
|
|
624
|
+
changed: false
|
|
625
|
+
};
|
|
626
|
+
const entry = {
|
|
627
|
+
type: "command",
|
|
628
|
+
command: req.command,
|
|
629
|
+
...req.timeout !== void 0 ? { timeout: req.timeout } : {}
|
|
630
|
+
};
|
|
631
|
+
const matchingGroupIndex = eventGroups.findIndex((group) => group.matcher === req.matcher);
|
|
632
|
+
const updatedGroups = matchingGroupIndex === -1 ? [...eventGroups, {
|
|
633
|
+
...req.matcher !== void 0 ? { matcher: req.matcher } : {},
|
|
634
|
+
hooks: [entry]
|
|
635
|
+
}] : eventGroups.map((group, index) => index === matchingGroupIndex ? {
|
|
636
|
+
...group,
|
|
637
|
+
hooks: [...group.hooks, entry]
|
|
638
|
+
} : group);
|
|
639
|
+
return {
|
|
640
|
+
hooks: {
|
|
641
|
+
...hooks,
|
|
642
|
+
hooks: {
|
|
643
|
+
...hooks.hooks ?? {},
|
|
644
|
+
[req.event]: updatedGroups
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
result: { added: true },
|
|
648
|
+
changed: true
|
|
649
|
+
};
|
|
650
|
+
});
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Remove hook entries from the specified config scope.
|
|
655
|
+
*
|
|
656
|
+
* Removes all hooks where `entry.event === req.event` and
|
|
657
|
+
* `entry.command.includes(req.match.commandContains)`.
|
|
658
|
+
* @param req - Removal criteria and targeting options. `scope` selects the
|
|
659
|
+
* config file; `projectDir` is required when `scope` is `'project'`.
|
|
660
|
+
* `event` is the event name to match. `match.commandContains` is the
|
|
661
|
+
* command substring filter — any hook whose command contains this string
|
|
662
|
+
* is removed.
|
|
663
|
+
* @returns `{ removed: n }` where `n` is the count of removed hooks.
|
|
664
|
+
*/
|
|
665
|
+
async removeHook(req) {
|
|
666
|
+
const filePath = this.resolvePathForScope(req.scope, req.projectDir);
|
|
667
|
+
const { result } = await this.modifyHooksFile(filePath, (hooks) => {
|
|
668
|
+
const eventGroups = hooks.hooks?.[req.event] ?? [];
|
|
669
|
+
let removed = 0;
|
|
670
|
+
const updatedGroups = eventGroups.map((group) => {
|
|
671
|
+
const remainingHandlers = group.hooks.filter((handler) => {
|
|
672
|
+
const parseResult = CodexNativeCommandHookSchema.safeParse(handler);
|
|
673
|
+
const shouldRemove = parseResult.success && parseResult.data.command.includes(req.match.commandContains);
|
|
674
|
+
if (shouldRemove) removed += 1;
|
|
675
|
+
return !shouldRemove;
|
|
676
|
+
});
|
|
677
|
+
return {
|
|
678
|
+
...group,
|
|
679
|
+
hooks: remainingHandlers
|
|
680
|
+
};
|
|
681
|
+
}).filter((group) => group.hooks.length > 0);
|
|
682
|
+
if (removed === 0) return {
|
|
683
|
+
hooks,
|
|
684
|
+
result: { removed },
|
|
685
|
+
changed: false
|
|
686
|
+
};
|
|
687
|
+
const updatedHooksByEvent = { ...hooks.hooks ?? {} };
|
|
688
|
+
if (updatedGroups.length === 0) delete updatedHooksByEvent[req.event];
|
|
689
|
+
else updatedHooksByEvent[req.event] = updatedGroups;
|
|
690
|
+
return {
|
|
691
|
+
hooks: {
|
|
692
|
+
...hooks,
|
|
693
|
+
hooks: updatedHooksByEvent
|
|
694
|
+
},
|
|
695
|
+
result: { removed },
|
|
696
|
+
changed: true
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Resolve config file path for a given scope.
|
|
703
|
+
*
|
|
704
|
+
* For `'project'` scope, `projectDir` must be provided. For `'global'`
|
|
705
|
+
* scope, `projectDir` is ignored.
|
|
706
|
+
* @param scope - Target config scope.
|
|
707
|
+
* @param projectDir - Optional absolute path to the project root.
|
|
708
|
+
* @returns Absolute path to the `hooks.json` file for the given scope.
|
|
709
|
+
* @throws When `scope === 'project'` and `projectDir` is absent.
|
|
710
|
+
*/
|
|
711
|
+
resolvePathForScope(scope, projectDir) {
|
|
712
|
+
const paths = this.resolvePaths(projectDir);
|
|
713
|
+
if (scope === "project") {
|
|
714
|
+
if (!paths.projectHooks) throw new Error("Cannot access project scope: projectDir is required");
|
|
715
|
+
return paths.projectHooks;
|
|
716
|
+
}
|
|
717
|
+
return paths.globalHooks;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Resolve settings paths, applying the optional test override when present.
|
|
721
|
+
* @param projectDir - Optional project root passed through to
|
|
722
|
+
* {@link resolveCodexSettingsPaths} when no override is active.
|
|
723
|
+
* @returns Resolved settings paths.
|
|
724
|
+
*/
|
|
725
|
+
resolvePaths(projectDir) {
|
|
726
|
+
if (this.pathsOverride !== void 0) return this.pathsOverride;
|
|
727
|
+
return resolveCodexSettingsPaths(projectDir, this.configDir);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Check whether the current process can write to a path.
|
|
731
|
+
*
|
|
732
|
+
* First tests the file itself (when it exists). If that check fails or the
|
|
733
|
+
* file does not yet exist, walks up from the parent directory until an
|
|
734
|
+
* existing ancestor is found and checks write permission there. This handles
|
|
735
|
+
* the fresh-install case where neither the file nor its `.codex/` parent
|
|
736
|
+
* exist yet — the write helper creates the tree via `mkdir({ recursive })`,
|
|
737
|
+
* so writability depends on the nearest existing ancestor.
|
|
738
|
+
* @param filePath - Absolute path to the file to test.
|
|
739
|
+
* @returns `true` when the process has write access, `false` otherwise.
|
|
740
|
+
*/
|
|
741
|
+
async isWritable(filePath) {
|
|
742
|
+
try {
|
|
743
|
+
await fs.access(filePath, fs.constants.W_OK);
|
|
744
|
+
return true;
|
|
745
|
+
} catch {
|
|
746
|
+
let candidate = path.dirname(filePath);
|
|
747
|
+
for (;;) try {
|
|
748
|
+
await fs.access(candidate, fs.constants.W_OK);
|
|
749
|
+
return true;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (error.code !== "ENOENT") return false;
|
|
752
|
+
const parent = path.dirname(candidate);
|
|
753
|
+
if (parent === candidate) return false;
|
|
754
|
+
candidate = parent;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Read and parse a Codex `hooks.json` file.
|
|
760
|
+
*
|
|
761
|
+
* Returns an empty array when the file does not exist (`ENOENT`) or when no
|
|
762
|
+
* command hooks are configured. Re-throws `SyntaxError` on corrupt JSON and
|
|
763
|
+
* permission errors without swallowing. Throws a descriptive `Error` when the
|
|
764
|
+
* native `hooks` tree fails schema validation.
|
|
765
|
+
* @param filePath - Absolute path to the `hooks.json` file.
|
|
766
|
+
* @returns Flattened command hook entries for public bus responses.
|
|
767
|
+
* @throws When a `hooks` field is present but does not conform to Codex's
|
|
768
|
+
* native grouped hook schema.
|
|
769
|
+
*/
|
|
770
|
+
async readHooksFile(filePath) {
|
|
771
|
+
return this.flattenHooksFile(await this.readHooksDocument(filePath));
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Read and parse a native Codex `hooks.json` file.
|
|
775
|
+
*
|
|
776
|
+
* Missing files resolve to an empty native document. Unknown top-level and
|
|
777
|
+
* nested fields are preserved by the schema so read-modify-write operations
|
|
778
|
+
* can update a target hook without dropping newer Codex fields.
|
|
779
|
+
* @param filePath - Absolute path to the `hooks.json` file.
|
|
780
|
+
* @returns Parsed native hooks document.
|
|
781
|
+
* @throws When JSON parsing fails or the `hooks` field is not a native Codex
|
|
782
|
+
* event-to-matcher-group map.
|
|
783
|
+
*/
|
|
784
|
+
async readHooksDocument(filePath) {
|
|
785
|
+
let content;
|
|
786
|
+
try {
|
|
787
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error.code === "ENOENT") return { ...EMPTY_HOOKS_FILE };
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
const parsed = JSON.parse(content);
|
|
793
|
+
const parseResult = CodexNativeHooksFileSchema.safeParse(parsed);
|
|
794
|
+
if (!parseResult.success) throw new Error(`Invalid hooks file ${filePath}: ${parseResult.error.message}`);
|
|
795
|
+
return parseResult.data;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Flatten a native Codex hooks document into the public command-hook view.
|
|
799
|
+
* @param document - Native Codex hooks document.
|
|
800
|
+
* @returns Flat command-hook entries ordered by event, matcher group, then
|
|
801
|
+
* handler order.
|
|
802
|
+
*/
|
|
803
|
+
flattenHooksFile(document) {
|
|
804
|
+
const entries = [];
|
|
805
|
+
for (const [event, groups] of Object.entries(document.hooks ?? {})) for (const group of groups) for (const rawHandler of group.hooks) {
|
|
806
|
+
const parseResult = CodexNativeCommandHookSchema.safeParse(rawHandler);
|
|
807
|
+
if (!parseResult.success) continue;
|
|
808
|
+
const handler = parseResult.data;
|
|
809
|
+
entries.push({
|
|
810
|
+
event,
|
|
811
|
+
...group.matcher !== void 0 ? { matcher: group.matcher } : {},
|
|
812
|
+
command: handler.command,
|
|
813
|
+
...handler.timeout !== void 0 ? { timeout: handler.timeout } : handler.timeoutSec !== void 0 ? { timeout: handler.timeoutSec } : {}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return entries;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Read, modify, and atomically write a Codex `hooks.json` file.
|
|
820
|
+
*
|
|
821
|
+
* Delegates serialization and atomic I/O to {@link atomicModifyFile}.
|
|
822
|
+
* The parent directory is created automatically when absent. Write errors
|
|
823
|
+
* surface to the current caller; the mutex queue continues regardless.
|
|
824
|
+
* @param filePath - Absolute path to the `hooks.json` file to modify.
|
|
825
|
+
* @param modifier - Pure function that receives the current native hooks
|
|
826
|
+
* document and returns the updated document, whether it changed, and a
|
|
827
|
+
* caller-defined result value.
|
|
828
|
+
* @returns The `result` value produced by the modifier.
|
|
829
|
+
*/
|
|
830
|
+
async modifyHooksFile(filePath, modifier) {
|
|
831
|
+
return { result: await atomicModifyFile(filePath, { ...EMPTY_HOOKS_FILE }, writeMutex, (rawContent) => {
|
|
832
|
+
const parseResult = CodexNativeHooksFileSchema.safeParse(rawContent);
|
|
833
|
+
if (!parseResult.success) throw new Error(`Invalid hooks file ${filePath}: ${parseResult.error.message}`);
|
|
834
|
+
return parseResult.data;
|
|
835
|
+
}, (hooks) => {
|
|
836
|
+
const { hooks: updated, result: innerResult, changed } = modifier(hooks);
|
|
837
|
+
return {
|
|
838
|
+
content: updated,
|
|
839
|
+
changed,
|
|
840
|
+
result: innerResult
|
|
841
|
+
};
|
|
842
|
+
}) };
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/runtime/config-prime-handler.ts
|
|
848
|
+
/**
|
|
849
|
+
* Codex config-prime handler.
|
|
850
|
+
*
|
|
851
|
+
* Handles the `client:codex.config.prime` delegation subject fired by the
|
|
852
|
+
* framework at three lifecycle phases: `managed-install`, `profile-create`,
|
|
853
|
+
* and `session-create`.
|
|
854
|
+
*
|
|
855
|
+
* The handler ensures that `check_for_update_on_startup = false` is present
|
|
856
|
+
* in the Codex `config.toml` file inside the target directory so that managed
|
|
857
|
+
* Codex processes never attempt to auto-update during a Makaio-controlled
|
|
858
|
+
* session. All other existing config keys are preserved; the key is replaced
|
|
859
|
+
* when it already exists with any value, and appended when absent.
|
|
860
|
+
*
|
|
861
|
+
* Writes are atomic (tmp-file + rename) to prevent readers from observing a
|
|
862
|
+
* partially written file. The operation is idempotent: when the file already
|
|
863
|
+
* contains the correct value the write is skipped entirely.
|
|
864
|
+
* @packageDocumentation
|
|
865
|
+
*/
|
|
866
|
+
/**
|
|
867
|
+
* Prime the Codex `config.toml` in the target config directory.
|
|
868
|
+
*
|
|
869
|
+
* Ensures that `check_for_update_on_startup = false` is set, preserving all
|
|
870
|
+
* other existing key-value pairs. Existing occurrences of the key (with any
|
|
871
|
+
* value) are replaced; the key is appended when absent. Empty lines are
|
|
872
|
+
* stripped to keep the file compact.
|
|
873
|
+
*
|
|
874
|
+
* The write is atomic via a temporary file + `fs.rename()` to prevent readers
|
|
875
|
+
* from observing a partially written file. The operation is idempotent: when
|
|
876
|
+
* the file already contains the correct value the disk is not touched.
|
|
877
|
+
* @param payload - Config prime request containing `clientId`, `configDir`,
|
|
878
|
+
* and `phase`. Additional optional fields (`binaryVersion`, `adapterName`,
|
|
879
|
+
* `projectDir`) are accepted but not used by the Codex prime handler.
|
|
880
|
+
* @returns `{ primed: true }` on success.
|
|
881
|
+
*/
|
|
882
|
+
async function handleCodexConfigPrime(payload) {
|
|
883
|
+
const configPath = path$1.join(payload.configDir, "config.toml");
|
|
884
|
+
await fs$1.mkdir(payload.configDir, { recursive: true });
|
|
885
|
+
let current = "";
|
|
886
|
+
try {
|
|
887
|
+
current = await fs$1.readFile(configPath, "utf-8");
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (error.code !== "ENOENT") throw error;
|
|
890
|
+
}
|
|
891
|
+
const lines = current.split(/\r?\n/).filter((line) => !line.trimStart().startsWith("check_for_update_on_startup")).filter((line) => line.length > 0);
|
|
892
|
+
const tableIndex = lines.findIndex((line) => line.trimStart().startsWith("["));
|
|
893
|
+
if (tableIndex === -1) lines.push("check_for_update_on_startup = false");
|
|
894
|
+
else lines.splice(tableIndex, 0, "check_for_update_on_startup = false");
|
|
895
|
+
const updated = `${lines.join("\n")}\n`;
|
|
896
|
+
if (updated === current) return { primed: true };
|
|
897
|
+
const tmpPath = path$1.join(payload.configDir, `config.toml.${randomUUID()}.tmp`);
|
|
898
|
+
await fs$1.writeFile(tmpPath, updated, "utf-8");
|
|
899
|
+
await fs$1.rename(tmpPath, configPath);
|
|
900
|
+
return { primed: true };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/runtime/hook-normalizer.ts
|
|
905
|
+
/**
|
|
906
|
+
* Pure normalizer for Codex CLI hook events.
|
|
907
|
+
*
|
|
908
|
+
* Maps the Codex-native hook event names emitted on
|
|
909
|
+
* `client:codex.hook.received` to their corresponding
|
|
910
|
+
* `client.session.*` observed-semantics subjects.
|
|
911
|
+
*
|
|
912
|
+
* **Mapping table** (Codex event → global subject):
|
|
913
|
+
*
|
|
914
|
+
* | Codex event name | Global subject |
|
|
915
|
+
* |--------------------------|------------------------------------------|
|
|
916
|
+
* | `SessionStart` | `client.session.started` |
|
|
917
|
+
* | `UserPromptSubmit` | `client.session.userPrompt.submitted` |
|
|
918
|
+
* | `Stop` | `client.session.turn.completed` |
|
|
919
|
+
* | `PreToolUse` | `client.session.tool.pre` |
|
|
920
|
+
* | `PostToolUse` | `client.session.tool.post` |
|
|
921
|
+
*
|
|
922
|
+
* All other event names are returned as `null` — they are kept raw only and
|
|
923
|
+
* are never emitted into the global `client.*` namespace.
|
|
924
|
+
*
|
|
925
|
+
* **Source notes:** The Codex CLI hook event names above reflect the OpenAI
|
|
926
|
+
* Codex CLI hook system as documented at the time of authoring. If the binary
|
|
927
|
+
* changes its hook names, update {@link CODEX_EVENT_MAP} accordingly.
|
|
928
|
+
* @packageDocumentation
|
|
929
|
+
*/
|
|
930
|
+
/**
|
|
931
|
+
* Static map from Codex-native hook event name to the matching global subject.
|
|
932
|
+
*
|
|
933
|
+
* Update this map when the Codex CLI exposes new hook names that correspond
|
|
934
|
+
* to global session lifecycle events.
|
|
935
|
+
*/
|
|
936
|
+
const CODEX_EVENT_MAP = new Map([
|
|
937
|
+
["SessionStart", ClientSubjects.session.started],
|
|
938
|
+
["UserPromptSubmit", ClientSubjects.session.userPrompt.submitted],
|
|
939
|
+
["Stop", ClientSubjects.session.turn.completed],
|
|
940
|
+
["PreToolUse", ClientSubjects.session.tool.pre],
|
|
941
|
+
["PostToolUse", ClientSubjects.session.tool.post]
|
|
942
|
+
]);
|
|
943
|
+
/**
|
|
944
|
+
* Extract optional session identifier from a raw Codex hook payload.
|
|
945
|
+
*
|
|
946
|
+
* Codex may report the session ID under `session_id` or `thread_id`.
|
|
947
|
+
* Both are checked because early events may use `thread_id` before a
|
|
948
|
+
* canonical session is established.
|
|
949
|
+
* @param payload - Raw hook payload object forwarded by the ingress bridge
|
|
950
|
+
* @returns Resolved adapter session ID string, or `undefined` when absent
|
|
951
|
+
*/
|
|
952
|
+
function extractAdapterSessionId(payload) {
|
|
953
|
+
return pickNonEmptyString(payload, "session_id") ?? pickNonEmptyString(payload, "thread_id");
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Extract optional tool name from a raw Codex hook payload.
|
|
957
|
+
*
|
|
958
|
+
* Codex reports the tool name under `tool_name` for pre/post tool calls.
|
|
959
|
+
* @param payload - Raw hook payload object
|
|
960
|
+
* @returns Tool name string, or `undefined` when absent
|
|
961
|
+
*/
|
|
962
|
+
function extractToolName(payload) {
|
|
963
|
+
return pickNonEmptyString(payload, "tool_name");
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Extract optional tool call correlation ID from a raw Codex hook payload.
|
|
967
|
+
* @param payload - Raw hook payload object
|
|
968
|
+
* @returns Tool call ID string, or `undefined` when absent
|
|
969
|
+
*/
|
|
970
|
+
function extractToolCallId(payload) {
|
|
971
|
+
return pickNonEmptyString(payload, "call_id");
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Extract optional tool execution outcome from a raw Codex post-tool payload.
|
|
975
|
+
*
|
|
976
|
+
* Codex reports whether the tool call succeeded under `success`.
|
|
977
|
+
* @param payload - Raw hook payload object
|
|
978
|
+
* @returns Boolean success flag, or `undefined` when absent
|
|
979
|
+
*/
|
|
980
|
+
function extractSuccess(payload) {
|
|
981
|
+
return typeof payload["success"] === "boolean" ? payload["success"] : void 0;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Extract optional prompt text from a raw Codex user-prompt payload.
|
|
985
|
+
* @param payload - Raw hook payload object
|
|
986
|
+
* @returns Non-empty prompt string, or `undefined` when absent
|
|
987
|
+
*/
|
|
988
|
+
function extractPrompt(payload) {
|
|
989
|
+
return pickNonEmptyString(payload, "prompt");
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Normalize a raw Codex hook payload into a `client.session.*` event.
|
|
993
|
+
*
|
|
994
|
+
* Returns `null` for unknown or not-yet-modeled event names so the caller
|
|
995
|
+
* skips global emission and keeps the event raw-only in `client:codex.*`.
|
|
996
|
+
* @param raw - Raw hook payload delivered on `client:codex.hook.received`
|
|
997
|
+
* @returns Normalized event with subject and typed payload, or `null` when
|
|
998
|
+
* the event name is unknown
|
|
999
|
+
*/
|
|
1000
|
+
function normalizeCodexHook(raw) {
|
|
1001
|
+
const subject = CODEX_EVENT_MAP.get(raw.eventName);
|
|
1002
|
+
if (subject === void 0) return null;
|
|
1003
|
+
const base = {
|
|
1004
|
+
clientId: "codex",
|
|
1005
|
+
source: "native-hook",
|
|
1006
|
+
observedAt: raw.receivedAt,
|
|
1007
|
+
adapterSessionId: extractAdapterSessionId(raw.payload),
|
|
1008
|
+
metadata: raw.metadata
|
|
1009
|
+
};
|
|
1010
|
+
switch (subject) {
|
|
1011
|
+
case ClientSubjects.session.started: return {
|
|
1012
|
+
subject,
|
|
1013
|
+
payload: { ...base }
|
|
1014
|
+
};
|
|
1015
|
+
case ClientSubjects.session.userPrompt.submitted: return {
|
|
1016
|
+
subject,
|
|
1017
|
+
payload: {
|
|
1018
|
+
...base,
|
|
1019
|
+
prompt: extractPrompt(raw.payload)
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
case ClientSubjects.session.turn.completed: return {
|
|
1023
|
+
subject,
|
|
1024
|
+
payload: { ...base }
|
|
1025
|
+
};
|
|
1026
|
+
case ClientSubjects.session.tool.pre: return {
|
|
1027
|
+
subject,
|
|
1028
|
+
payload: {
|
|
1029
|
+
...base,
|
|
1030
|
+
toolName: extractToolName(raw.payload),
|
|
1031
|
+
toolCallId: extractToolCallId(raw.payload)
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
case ClientSubjects.session.tool.post: return {
|
|
1035
|
+
subject,
|
|
1036
|
+
payload: {
|
|
1037
|
+
...base,
|
|
1038
|
+
toolName: extractToolName(raw.payload),
|
|
1039
|
+
toolCallId: extractToolCallId(raw.payload),
|
|
1040
|
+
success: extractSuccess(raw.payload)
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
default: return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/runtime/namespace.ts
|
|
1049
|
+
/**
|
|
1050
|
+
* Codex client namespace registration.
|
|
1051
|
+
*
|
|
1052
|
+
* Registers `client:codex` on the singleton bus using the shared
|
|
1053
|
+
* {@link createClientNamespace} factory, which pre-registers the raw catch-all
|
|
1054
|
+
* hook ingress subject `hook.received` with the canonical
|
|
1055
|
+
* {@link RawClientHookPayloadSchema}.
|
|
1056
|
+
*
|
|
1057
|
+
* Additional subjects registered here:
|
|
1058
|
+
* - `config.hooks.list` — list effective hook configuration
|
|
1059
|
+
* - `config.hooks.add` — add a hook entry to a config scope
|
|
1060
|
+
* - `config.hooks.remove` — remove hook entries matching a pattern
|
|
1061
|
+
* - `config.prime` — blocking config-prime lifecycle hook
|
|
1062
|
+
* - `wiring.list` — list all wiring entries with installation status
|
|
1063
|
+
* - `wiring.apply` — install wiring entries into the target scope
|
|
1064
|
+
* - `wiring.remove` — uninstall wiring entries from the target scope
|
|
1065
|
+
* - `sessionConfig.setup` — seed an isolated session config directory
|
|
1066
|
+
*
|
|
1067
|
+
* **Subject conventions:**
|
|
1068
|
+
* - Raw Codex-native events flow in the `client:codex.*` namespace only.
|
|
1069
|
+
* - Normalized lifecycle observations flow in the global `client.session.*`
|
|
1070
|
+
* namespace after the {@link CodexHookNormalizer} maps them.
|
|
1071
|
+
* @packageDocumentation
|
|
1072
|
+
*/
|
|
1073
|
+
const { subjects, namespaceDomain } = createClientNamespace("codex", {
|
|
1074
|
+
...CodexConfigSchemas,
|
|
1075
|
+
...CodexWiringSchemas,
|
|
1076
|
+
"config.prime": ClientConfigPrimeSchema,
|
|
1077
|
+
"sessionConfig.setup": {
|
|
1078
|
+
request: SessionConfigSetupRequestSchema,
|
|
1079
|
+
response: SessionConfigSetupResponseSchema
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
/**
|
|
1083
|
+
* Typed bus subjects for the Codex client namespace (`client:codex.*`).
|
|
1084
|
+
*
|
|
1085
|
+
* Exposes the raw hook ingress subject, config management subjects, wiring
|
|
1086
|
+
* management subjects, the config-prime lifecycle hook, and the session config
|
|
1087
|
+
* setup subject:
|
|
1088
|
+
* - `CodexClientSubjects.hook.received` → `client:codex.hook.received`
|
|
1089
|
+
* - `CodexClientSubjects.config.hooks.list` → `client:codex.config.hooks.list`
|
|
1090
|
+
* - `CodexClientSubjects.config.hooks.add` → `client:codex.config.hooks.add`
|
|
1091
|
+
* - `CodexClientSubjects.config.hooks.remove` → `client:codex.config.hooks.remove`
|
|
1092
|
+
* - `CodexClientSubjects.config.prime` → `client:codex.config.prime`
|
|
1093
|
+
* - `CodexClientSubjects.wiring.list` → `client:codex.wiring.list`
|
|
1094
|
+
* - `CodexClientSubjects.wiring.apply` → `client:codex.wiring.apply`
|
|
1095
|
+
* - `CodexClientSubjects.wiring.remove` → `client:codex.wiring.remove`
|
|
1096
|
+
* - `CodexClientSubjects.sessionConfig.setup` → `client:codex.sessionConfig.setup`
|
|
1097
|
+
*/
|
|
1098
|
+
const CodexClientSubjects = subjects;
|
|
1099
|
+
/**
|
|
1100
|
+
* Fully-qualified namespace domain string for the Codex client.
|
|
1101
|
+
*
|
|
1102
|
+
* Value: `'client:codex'`
|
|
1103
|
+
*/
|
|
1104
|
+
const CODEX_CLIENT_NAMESPACE = namespaceDomain;
|
|
1105
|
+
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/runtime/session-config-handler.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Codex session config setup handler.
|
|
1110
|
+
*
|
|
1111
|
+
* Implements the `client:codex.sessionConfig.setup` delegation request by
|
|
1112
|
+
* materializing the Codex config files needed for an isolated session
|
|
1113
|
+
* directory.
|
|
1114
|
+
*
|
|
1115
|
+
* The handler copies `config.toml` and `auth.json` from the base config
|
|
1116
|
+
* directory (when present) into the session-scoped directory, then primes the
|
|
1117
|
+
* session directory to ensure `check_for_update_on_startup = false` is set.
|
|
1118
|
+
*
|
|
1119
|
+
* When `sessionDir` and `baseConfigDir` resolve to the same path (the
|
|
1120
|
+
* framework passes `sessionDir` as `baseConfigDir` when no profile is
|
|
1121
|
+
* configured) the copy step is skipped and only the prime step runs.
|
|
1122
|
+
*
|
|
1123
|
+
* Returns `{ env: { CODEX_HOME: sessionDir } }` so the spawned Codex process
|
|
1124
|
+
* inherits the isolated session directory as its configuration root.
|
|
1125
|
+
* @packageDocumentation
|
|
1126
|
+
*/
|
|
1127
|
+
/**
|
|
1128
|
+
* Copy a file if it exists at the source path; silently skip when absent.
|
|
1129
|
+
* @param src - Source file path.
|
|
1130
|
+
* @param dst - Destination file path.
|
|
1131
|
+
*/
|
|
1132
|
+
async function copyIfPresent(src, dst) {
|
|
1133
|
+
try {
|
|
1134
|
+
await fs$1.copyFile(src, dst);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
if (error.code !== "ENOENT") throw error;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Handle `client:codex.sessionConfig.setup` by seeding the session-scoped
|
|
1141
|
+
* directory with the appropriate Codex config files.
|
|
1142
|
+
*
|
|
1143
|
+
* Steps:
|
|
1144
|
+
* 1. Create `sessionDir` (recursive, no-op when it already exists).
|
|
1145
|
+
* 2. When `sessionDir` and `baseConfigDir` are distinct paths, copy
|
|
1146
|
+
* `config.toml` and `auth.json` from `baseConfigDir` into `sessionDir`
|
|
1147
|
+
* (each copy is skipped when the source file does not exist).
|
|
1148
|
+
* 3. Prime `sessionDir` via {@link handleCodexConfigPrime} to ensure
|
|
1149
|
+
* `check_for_update_on_startup = false` is set.
|
|
1150
|
+
* @param payload - Session config setup delegation payload. Only `sessionDir`,
|
|
1151
|
+
* `baseConfigDir`, and `projectDir` are used; `platform` and
|
|
1152
|
+
* `configInheritance` are accepted for interface compatibility but are not
|
|
1153
|
+
* required for Codex's simpler config model.
|
|
1154
|
+
* @returns Environment variables for the spawned Codex process: `CODEX_HOME`
|
|
1155
|
+
* pointing to `sessionDir`.
|
|
1156
|
+
*/
|
|
1157
|
+
async function handleCodexSessionConfigSetup(payload) {
|
|
1158
|
+
const { sessionDir, baseConfigDir, projectDir } = payload;
|
|
1159
|
+
await fs$1.mkdir(sessionDir, { recursive: true });
|
|
1160
|
+
if (path$1.resolve(sessionDir) !== path$1.resolve(baseConfigDir)) {
|
|
1161
|
+
await copyIfPresent(path$1.join(baseConfigDir, "config.toml"), path$1.join(sessionDir, "config.toml"));
|
|
1162
|
+
await copyIfPresent(path$1.join(baseConfigDir, "auth.json"), path$1.join(sessionDir, "auth.json"));
|
|
1163
|
+
}
|
|
1164
|
+
await handleCodexConfigPrime({
|
|
1165
|
+
clientId: "codex",
|
|
1166
|
+
configDir: sessionDir,
|
|
1167
|
+
phase: "session-create",
|
|
1168
|
+
...projectDir !== void 0 ? { projectDir } : {}
|
|
1169
|
+
});
|
|
1170
|
+
return { env: { CODEX_HOME: sessionDir } };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
//#endregion
|
|
1174
|
+
//#region src/runtime/wiring.ts
|
|
1175
|
+
/**
|
|
1176
|
+
* Substring used to identify Makaio-managed Codex hook commands.
|
|
1177
|
+
*
|
|
1178
|
+
* The full command for a given event takes the form:
|
|
1179
|
+
* `<makaioCommand> hook received codex <EventName>`
|
|
1180
|
+
*
|
|
1181
|
+
* This sentinel is written verbatim by {@link applyCodexWiring} and used as
|
|
1182
|
+
* the `commandContains` filter in {@link removeCodexWiring}.
|
|
1183
|
+
*/
|
|
1184
|
+
const CODEX_HOOK_COMMAND_SENTINEL = "hook received codex";
|
|
1185
|
+
/**
|
|
1186
|
+
* Descriptors for all session-events hooks derived from the client definition.
|
|
1187
|
+
*
|
|
1188
|
+
* Only events with a defined `frameworkSubject` are included — events without
|
|
1189
|
+
* one are Codex-internal and do not need framework wiring.
|
|
1190
|
+
*/
|
|
1191
|
+
const SESSION_EVENTS = deriveSessionEventDescriptors(clientDefinition);
|
|
1192
|
+
/**
|
|
1193
|
+
* Build the wiring entry list, annotated with installation status, by
|
|
1194
|
+
* comparing the expected entries against the currently installed Codex hooks.
|
|
1195
|
+
*
|
|
1196
|
+
* Reads hooks from the global scope only when `projectDir` is absent; includes
|
|
1197
|
+
* project-scope hooks when `projectDir` is provided.
|
|
1198
|
+
* @param settings - {@link CodexClientSettings} instance for hook I/O.
|
|
1199
|
+
* @param makaioCommand - Base makaio shell command written into the config.
|
|
1200
|
+
* @param projectDir - Optional absolute project directory. When absent only
|
|
1201
|
+
* the global scope is checked.
|
|
1202
|
+
* @returns Object containing all wiring entries with `installed` flags.
|
|
1203
|
+
*/
|
|
1204
|
+
async function buildCodexWiringList(settings, makaioCommand, projectDir) {
|
|
1205
|
+
const { effective } = await settings.listHooks(projectDir !== void 0 ? { projectDir } : {});
|
|
1206
|
+
return { entries: SESSION_EVENTS.map(({ eventName }) => {
|
|
1207
|
+
const sentinel = `${CODEX_HOOK_COMMAND_SENTINEL} ${eventName}`;
|
|
1208
|
+
const command = buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName);
|
|
1209
|
+
return {
|
|
1210
|
+
group: "session-events",
|
|
1211
|
+
name: eventName,
|
|
1212
|
+
installed: effective.some((entry) => entry.event === eventName && entry.command.includes(sentinel)),
|
|
1213
|
+
command
|
|
1214
|
+
};
|
|
1215
|
+
}) };
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Install all session-event wiring entries into the specified scope.
|
|
1219
|
+
*
|
|
1220
|
+
* The operation uses replace semantics when `makaioCommand` changes: if a hook
|
|
1221
|
+
* for an event already contains the sentinel but with a different command
|
|
1222
|
+
* prefix, the old hook is removed before the new one is added. When the
|
|
1223
|
+
* identical command is already present the entry is skipped unchanged.
|
|
1224
|
+
* @param settings - {@link CodexClientSettings} instance for hook I/O.
|
|
1225
|
+
* @param scope - Target scope (`'global'` or `'project'`).
|
|
1226
|
+
* @param makaioCommand - Base makaio shell command written into each hook.
|
|
1227
|
+
* @param projectDir - Absolute project directory. Required when `scope` is
|
|
1228
|
+
* `'project'`; ignored for `'global'`.
|
|
1229
|
+
* @returns Counts of applied (newly written or replaced) and skipped (already
|
|
1230
|
+
* present) entries.
|
|
1231
|
+
*/
|
|
1232
|
+
async function applyCodexWiring(settings, scope, makaioCommand, projectDir) {
|
|
1233
|
+
let applied = 0;
|
|
1234
|
+
let skipped = 0;
|
|
1235
|
+
const { perScope } = await settings.listHooks(projectDir !== void 0 ? { projectDir } : {});
|
|
1236
|
+
const scopeHooks = perScope.find((s) => s.scope === scope)?.hooks ?? [];
|
|
1237
|
+
for (const { eventName } of SESSION_EVENTS) {
|
|
1238
|
+
const sentinel = `${CODEX_HOOK_COMMAND_SENTINEL} ${eventName}`;
|
|
1239
|
+
const command = buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName);
|
|
1240
|
+
const existingEntry = scopeHooks.find((entry) => entry.event === eventName && entry.command.includes(sentinel));
|
|
1241
|
+
if (existingEntry !== void 0) {
|
|
1242
|
+
if (existingEntry.command === command) {
|
|
1243
|
+
skipped += 1;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
await settings.removeHook({
|
|
1247
|
+
scope,
|
|
1248
|
+
event: eventName,
|
|
1249
|
+
match: { commandContains: sentinel },
|
|
1250
|
+
...projectDir !== void 0 ? { projectDir } : {}
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
if ((await settings.addHook({
|
|
1254
|
+
scope,
|
|
1255
|
+
event: eventName,
|
|
1256
|
+
command,
|
|
1257
|
+
...projectDir !== void 0 ? { projectDir } : {}
|
|
1258
|
+
})).added) applied += 1;
|
|
1259
|
+
else skipped += 1;
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
applied,
|
|
1263
|
+
skipped
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Remove all Makaio-managed session-event wiring entries from the specified
|
|
1268
|
+
* scope.
|
|
1269
|
+
*
|
|
1270
|
+
* Entries that are not present are silently ignored — the operation is
|
|
1271
|
+
* idempotent.
|
|
1272
|
+
* @param settings - {@link CodexClientSettings} instance for hook I/O.
|
|
1273
|
+
* @param scope - Target scope (`'global'` or `'project'`).
|
|
1274
|
+
* @param projectDir - Absolute project directory. Required when `scope` is
|
|
1275
|
+
* `'project'`; ignored for `'global'`.
|
|
1276
|
+
* @returns Count of entries actually removed from the config file.
|
|
1277
|
+
*/
|
|
1278
|
+
async function removeCodexWiring(settings, scope, projectDir) {
|
|
1279
|
+
let removed = 0;
|
|
1280
|
+
for (const { eventName } of SESSION_EVENTS) {
|
|
1281
|
+
const result = await settings.removeHook({
|
|
1282
|
+
scope,
|
|
1283
|
+
event: eventName,
|
|
1284
|
+
match: { commandContains: `${CODEX_HOOK_COMMAND_SENTINEL} ${eventName}` },
|
|
1285
|
+
...projectDir !== void 0 ? { projectDir } : {}
|
|
1286
|
+
});
|
|
1287
|
+
removed += result.removed;
|
|
1288
|
+
}
|
|
1289
|
+
return { removed };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/runtime/codex-client-session-service.ts
|
|
1294
|
+
/** Stable client ID for Codex — used to filter `client.runtime.started` events. */
|
|
1295
|
+
const CLIENT_ID = "codex";
|
|
1296
|
+
/**
|
|
1297
|
+
* Maximum number of adapter-managed session IDs retained in
|
|
1298
|
+
* {@link CodexClientSessionService.managedAdapterSessionIds}.
|
|
1299
|
+
*
|
|
1300
|
+
* Concurrent active Codex sessions are typically single-digit, so this
|
|
1301
|
+
* cap is a safety net against unbounded growth in long-lived processes. When
|
|
1302
|
+
* the cap is reached, the oldest recorded ID is evicted (FIFO) before the new
|
|
1303
|
+
* one is inserted.
|
|
1304
|
+
*/
|
|
1305
|
+
const MANAGED_SESSION_CAP = 1e4;
|
|
1306
|
+
/**
|
|
1307
|
+
* Service that normalizes raw Codex hook events into global
|
|
1308
|
+
* `client.session.*` observed-semantics events and handles Codex config
|
|
1309
|
+
* management requests on `client:codex.config.hooks.*`.
|
|
1310
|
+
*
|
|
1311
|
+
* Lifecycle:
|
|
1312
|
+
* 1. `init()` — subscribes to `client:codex.hook.received`, subscribes to
|
|
1313
|
+
* `client.runtime.started` for the adapter-managed session gate, and
|
|
1314
|
+
* registers request handlers for `config.hooks.list`, `config.hooks.add`,
|
|
1315
|
+
* `config.hooks.remove`, `config.prime`, `wiring.list`, `wiring.apply`,
|
|
1316
|
+
* `wiring.remove`, and `sessionConfig.setup`.
|
|
1317
|
+
* 2. On each incoming raw event, calls {@link normalizeCodexHook}.
|
|
1318
|
+
* 3. Emits the normalized subject when the event is recognized; silently
|
|
1319
|
+
* ignores unknown events. Normalized `client.session.*` events are
|
|
1320
|
+
* suppressed when the `adapterSessionId` is already in the adapter-managed
|
|
1321
|
+
* set.
|
|
1322
|
+
* 4. `destroy()` — unsubscribes all handlers automatically via `BaseService`.
|
|
1323
|
+
*/
|
|
1324
|
+
var CodexClientSessionService = class extends BaseService {
|
|
1325
|
+
/** Optional injected settings I/O delegate for tests. */
|
|
1326
|
+
settingsOverride;
|
|
1327
|
+
/** Cached active config-dir resolution; reset when the active Codex version changes. */
|
|
1328
|
+
cachedConfigDir;
|
|
1329
|
+
/**
|
|
1330
|
+
* Set of `adapterSessionId` values known to be owned by an adapter-managed
|
|
1331
|
+
* Codex runtime. Populated by {@link handleRuntimeStarted} when a
|
|
1332
|
+
* `client.runtime.started` event arrives with `clientId === CLIENT_ID` and
|
|
1333
|
+
* `source.layer === 'adapter'`.
|
|
1334
|
+
*
|
|
1335
|
+
* Bounded at {@link MANAGED_SESSION_CAP} entries — the oldest ID is evicted
|
|
1336
|
+
* (FIFO) when the cap is reached.
|
|
1337
|
+
*
|
|
1338
|
+
* Used by {@link handleHookReceived} to gate duplicate `client.session.*`
|
|
1339
|
+
* emissions for sessions that the adapter path already covers.
|
|
1340
|
+
*/
|
|
1341
|
+
managedAdapterSessionIds = /* @__PURE__ */ new Set();
|
|
1342
|
+
/**
|
|
1343
|
+
* Creates a new Codex client session service.
|
|
1344
|
+
* @param bus - Bus instance used for subscribing and emitting events
|
|
1345
|
+
* @param settings - Optional {@link CodexClientSettings} instance for tests
|
|
1346
|
+
* that need exact filesystem paths. Production callers should omit it so
|
|
1347
|
+
* the service can resolve the active managed config dir via the bus.
|
|
1348
|
+
*/
|
|
1349
|
+
constructor(bus = MakaioBus, settings) {
|
|
1350
|
+
super(bus);
|
|
1351
|
+
this.settingsOverride = settings;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Register the raw hook ingress handler, config management request handlers,
|
|
1355
|
+
* wiring management request handlers, the config-prime lifecycle handler,
|
|
1356
|
+
* and the session config setup handler on the bus.
|
|
1357
|
+
*
|
|
1358
|
+
* Also subscribes to `client.runtime.started` to track adapter-managed
|
|
1359
|
+
* sessions for the {@link handleHookReceived} suppression gate.
|
|
1360
|
+
*/
|
|
1361
|
+
onInit() {
|
|
1362
|
+
this.registerHandler(ClientSubjects.runtime.started, ({ payload }) => {
|
|
1363
|
+
this.handleRuntimeStarted(payload);
|
|
1364
|
+
});
|
|
1365
|
+
this.registerHandler(ClientSubjects.version.changed, ({ payload }) => {
|
|
1366
|
+
if (payload.clientId === CLIENT_ID) this.cachedConfigDir = void 0;
|
|
1367
|
+
});
|
|
1368
|
+
this.registerHandler(CodexClientSubjects.hook.received, async ({ payload }) => {
|
|
1369
|
+
await this.handleHookReceived(payload);
|
|
1370
|
+
});
|
|
1371
|
+
this.registerHandler(CodexClientSubjects.config.hooks.list, async (ctx) => {
|
|
1372
|
+
ctx.setResult(await (await this.createSettings()).listHooks(ctx.payload));
|
|
1373
|
+
});
|
|
1374
|
+
this.registerHandler(CodexClientSubjects.config.hooks.add, async (ctx) => {
|
|
1375
|
+
ctx.setResult(await (await this.createSettings()).addHook(ctx.payload));
|
|
1376
|
+
});
|
|
1377
|
+
this.registerHandler(CodexClientSubjects.config.hooks.remove, async (ctx) => {
|
|
1378
|
+
ctx.setResult(await (await this.createSettings()).removeHook(ctx.payload));
|
|
1379
|
+
});
|
|
1380
|
+
this.registerHandler(CodexClientSubjects.wiring.list, async (ctx) => {
|
|
1381
|
+
assertAbsoluteProjectDir(ctx.payload.projectDir);
|
|
1382
|
+
ctx.setResult(await buildCodexWiringList(await this.createSettings(), ctx.payload.makaioCommand, ctx.payload.projectDir));
|
|
1383
|
+
});
|
|
1384
|
+
this.registerHandler(CodexClientSubjects.wiring.apply, async (ctx) => {
|
|
1385
|
+
assertAbsoluteProjectDir(ctx.payload.projectDir);
|
|
1386
|
+
if (ctx.payload.scope === "project" && !ctx.payload.projectDir) throw new Error("projectDir is required when scope is 'project'");
|
|
1387
|
+
ctx.setResult(await applyCodexWiring(await this.createSettings(), ctx.payload.scope, ctx.payload.makaioCommand, ctx.payload.projectDir));
|
|
1388
|
+
});
|
|
1389
|
+
this.registerHandler(CodexClientSubjects.wiring.remove, async (ctx) => {
|
|
1390
|
+
assertAbsoluteProjectDir(ctx.payload.projectDir);
|
|
1391
|
+
if (ctx.payload.scope === "project" && !ctx.payload.projectDir) throw new Error("projectDir is required when scope is 'project'");
|
|
1392
|
+
ctx.setResult(await removeCodexWiring(await this.createSettings(), ctx.payload.scope, ctx.payload.projectDir));
|
|
1393
|
+
});
|
|
1394
|
+
this.registerHandler(CodexClientSubjects.config.prime, async (ctx) => {
|
|
1395
|
+
ctx.setResult(await handleCodexConfigPrime(ctx.payload));
|
|
1396
|
+
});
|
|
1397
|
+
this.registerHandler(CodexClientSubjects.sessionConfig.setup, async (ctx) => {
|
|
1398
|
+
ctx.setResult(await handleCodexSessionConfigSetup(ctx.payload));
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Clear the adapter-managed session ID set on teardown.
|
|
1403
|
+
*/
|
|
1404
|
+
onDestroy() {
|
|
1405
|
+
this.managedAdapterSessionIds.clear();
|
|
1406
|
+
this.cachedConfigDir = void 0;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Create a settings delegate for the active Codex config root.
|
|
1410
|
+
* @returns Settings instance bound to the managed config dir when available.
|
|
1411
|
+
*/
|
|
1412
|
+
async createSettings() {
|
|
1413
|
+
if (this.settingsOverride !== void 0) return this.settingsOverride;
|
|
1414
|
+
const configDir = await this.resolveConfigDir();
|
|
1415
|
+
return new CodexClientSettings(configDir !== void 0 ? { configDir } : void 0);
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Return the cached config directory promise, resolving it on first access.
|
|
1419
|
+
*
|
|
1420
|
+
* Missing binary resolution is a graceful fallback so config reads/writes can
|
|
1421
|
+
* still target native Codex config paths in framework-only or global-only
|
|
1422
|
+
* setups.
|
|
1423
|
+
* @returns Absolute managed config dir, or `undefined` to use native paths.
|
|
1424
|
+
*/
|
|
1425
|
+
resolveConfigDir() {
|
|
1426
|
+
if (this.cachedConfigDir === void 0) {
|
|
1427
|
+
const pendingConfigDir = this.doResolveConfigDir().then((configDir) => {
|
|
1428
|
+
if (configDir === void 0 && this.cachedConfigDir === pendingConfigDir) this.cachedConfigDir = void 0;
|
|
1429
|
+
return configDir;
|
|
1430
|
+
}, (error) => {
|
|
1431
|
+
if (this.cachedConfigDir === pendingConfigDir) this.cachedConfigDir = void 0;
|
|
1432
|
+
throw error;
|
|
1433
|
+
});
|
|
1434
|
+
this.cachedConfigDir = pendingConfigDir;
|
|
1435
|
+
}
|
|
1436
|
+
return this.cachedConfigDir;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Resolve the active Codex config dir via `client.resolveBinary`.
|
|
1440
|
+
* @returns Config directory returned by the binary resolver, or `undefined`
|
|
1441
|
+
* when no resolver/global binary is available.
|
|
1442
|
+
*/
|
|
1443
|
+
async doResolveConfigDir() {
|
|
1444
|
+
let result;
|
|
1445
|
+
try {
|
|
1446
|
+
result = await this.bus.requestOptional(ClientSubjects.resolveBinary, { clientId: CLIENT_ID });
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
if (isResolveBinaryMissingGlobalBinary(error)) return;
|
|
1449
|
+
throw error;
|
|
1450
|
+
}
|
|
1451
|
+
if (!result.handled) return;
|
|
1452
|
+
return result.data.configDir ?? void 0;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Record a runtime as adapter-managed when the evidence source is an adapter.
|
|
1456
|
+
*
|
|
1457
|
+
* Called for every `client.runtime.started` event. Only events whose
|
|
1458
|
+
* `clientId` equals `'codex'`, whose `source.layer` is `'adapter'`, and
|
|
1459
|
+
* that carry a non-empty `adapterSessionId` update the managed-sessions gate.
|
|
1460
|
+
* Events from other clients (e.g. `'claude-code'`, `'gemini'`) are ignored
|
|
1461
|
+
* unconditionally — their adapter sessions must not suppress Codex hook
|
|
1462
|
+
* emissions. Non-adapter sources (e.g. `'supervisor'`, `'statusline'`) are
|
|
1463
|
+
* also ignored to prevent accidental suppression of native hook paths.
|
|
1464
|
+
*
|
|
1465
|
+
* When the set reaches {@link MANAGED_SESSION_CAP}, the oldest entry is
|
|
1466
|
+
* evicted before the new ID is inserted.
|
|
1467
|
+
* @param payload - `client.runtime.started` payload
|
|
1468
|
+
*/
|
|
1469
|
+
handleRuntimeStarted(payload) {
|
|
1470
|
+
if (payload.clientId !== CLIENT_ID) return;
|
|
1471
|
+
if (payload.source.layer === "adapter" && payload.adapterSessionId) {
|
|
1472
|
+
if (this.managedAdapterSessionIds.has(payload.adapterSessionId)) return;
|
|
1473
|
+
if (this.managedAdapterSessionIds.size >= 1e4) {
|
|
1474
|
+
const oldest = this.managedAdapterSessionIds.values().next().value;
|
|
1475
|
+
if (oldest !== void 0) this.managedAdapterSessionIds.delete(oldest);
|
|
1476
|
+
}
|
|
1477
|
+
this.managedAdapterSessionIds.add(payload.adapterSessionId);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Translate a raw Codex hook event into a normalized `client.session.*` emission.
|
|
1482
|
+
*
|
|
1483
|
+
* Unknown / Codex-specific events produce no emission and are silently
|
|
1484
|
+
* ignored. The raw event remains observable on `client:codex.*` for
|
|
1485
|
+
* consumers that need Codex-native detail.
|
|
1486
|
+
* @param raw - Raw hook payload delivered on `client:codex.hook.received`
|
|
1487
|
+
*/
|
|
1488
|
+
async handleHookReceived(raw) {
|
|
1489
|
+
const normalized = normalizeCodexHook(raw);
|
|
1490
|
+
if (normalized === null) return;
|
|
1491
|
+
if (this.isAdapterManagedSession(normalized.payload.adapterSessionId)) return;
|
|
1492
|
+
switch (normalized.subject) {
|
|
1493
|
+
case ClientSubjects.session.started:
|
|
1494
|
+
await this.bus.emit(ClientSubjects.session.started, normalized.payload);
|
|
1495
|
+
break;
|
|
1496
|
+
case ClientSubjects.session.userPrompt.submitted:
|
|
1497
|
+
await this.bus.emit(ClientSubjects.session.userPrompt.submitted, normalized.payload);
|
|
1498
|
+
break;
|
|
1499
|
+
case ClientSubjects.session.turn.completed:
|
|
1500
|
+
await this.bus.emit(ClientSubjects.session.turn.completed, normalized.payload);
|
|
1501
|
+
break;
|
|
1502
|
+
case ClientSubjects.session.tool.pre:
|
|
1503
|
+
await this.bus.emit(ClientSubjects.session.tool.pre, normalized.payload);
|
|
1504
|
+
break;
|
|
1505
|
+
case ClientSubjects.session.tool.post:
|
|
1506
|
+
await this.bus.emit(ClientSubjects.session.tool.post, normalized.payload);
|
|
1507
|
+
break;
|
|
1508
|
+
default: throwUnhandledNormalizedEvent(normalized);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Determine whether a normalized native hook belongs to an adapter-managed
|
|
1513
|
+
* session whose global observed-semantics events are already emitted by the
|
|
1514
|
+
* adapter layer.
|
|
1515
|
+
* @param adapterSessionId - Adapter/session identifier from the normalized hook
|
|
1516
|
+
* @returns True when the native hook should remain raw-only
|
|
1517
|
+
*/
|
|
1518
|
+
isAdapterManagedSession(adapterSessionId) {
|
|
1519
|
+
return adapterSessionId !== void 0 && this.managedAdapterSessionIds.has(adapterSessionId);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
/**
|
|
1523
|
+
* Fail fast when the normalizer grows a new subject but service emission has
|
|
1524
|
+
* not been updated to preserve the normalized-event contract.
|
|
1525
|
+
*
|
|
1526
|
+
* The broad parameter type is intentional — the switch operates on
|
|
1527
|
+
* `SubjectDefinition` subject strings rather than a discriminated union, so
|
|
1528
|
+
* TypeScript cannot narrow `normalized` to `never` in the default branch.
|
|
1529
|
+
* Compile-time exhaustiveness is enforced by the normalizer's return type
|
|
1530
|
+
* and the matching set of case branches above.
|
|
1531
|
+
* @param event - Normalized event whose subject is not emitted above
|
|
1532
|
+
*/
|
|
1533
|
+
function throwUnhandledNormalizedEvent(event) {
|
|
1534
|
+
const subject = event.subject.subject;
|
|
1535
|
+
throw new Error(`Unhandled normalized Codex hook subject: ${subject}`);
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Return true when `client.resolveBinary` only failed because no Codex
|
|
1539
|
+
* executable was found. Config and wiring requests can still use Codex's
|
|
1540
|
+
* default native settings path in that case; other resolution failures should
|
|
1541
|
+
* propagate.
|
|
1542
|
+
* @param error - Error thrown by the bus request.
|
|
1543
|
+
* @returns True when the error is the global-binary fallback miss.
|
|
1544
|
+
*/
|
|
1545
|
+
function isResolveBinaryMissingGlobalBinary(error) {
|
|
1546
|
+
if (!(error instanceof RequestError)) return false;
|
|
1547
|
+
return error.subject?.endsWith("resolveBinary") === true && error.cause instanceof BinaryNotFoundError;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
//#endregion
|
|
1551
|
+
export { CodexNativeHooksFileSchema as _, CodexWiringSchemas as a, clientDefinition as b, CodexConfigHooksAddResponseSchema as c, CodexConfigHooksRemoveRequestSchema as d, CodexConfigHooksRemoveResponseSchema as f, CodexNativeHookMatcherGroupSchema as g, CodexNativeCommandHookSchema as h, normalizeCodexHook as i, CodexConfigHooksListRequestSchema as l, CodexHookEntrySchema as m, CODEX_CLIENT_NAMESPACE as n, AbsolutePathSchema as o, CodexConfigSchemas as p, CodexClientSubjects as r, CodexConfigHooksAddRequestSchema as s, CodexClientSessionService as t, CodexConfigHooksListResponseSchema as u, CodexScopeHookRecordSchema as v, CodexScopeSchema as y };
|