holo-codex 0.1.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.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,452 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { isRecord } from "./config.js";
7
+
8
+ export interface HookBinding {
9
+ id: string;
10
+ repoRoot: string;
11
+ worktreeRoot: string;
12
+ gitCommonDir?: string;
13
+ branch?: string;
14
+ runId?: string;
15
+ sessionIdHash?: string;
16
+ transcriptPathSha256?: string;
17
+ status: "active" | "stale" | "disabled";
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ lastSeenAt?: string;
21
+ }
22
+
23
+ export interface HookRouteContext {
24
+ cwd: string;
25
+ worktreeRoot: string;
26
+ gitCommonDir?: string;
27
+ branch?: string;
28
+ sessionId?: string;
29
+ turnId?: string;
30
+ transcriptPathSha256?: string;
31
+ }
32
+
33
+ export type HookRouteResult =
34
+ | { status: "matched"; binding: HookBinding; context: HookRouteContext; legacy: boolean }
35
+ | { status: "no_match"; context: HookRouteContext; reason: string; worktreeBinding?: boolean }
36
+ | { status: "ambiguous"; context: HookRouteContext; bindings: HookBinding[]; reason: string }
37
+ | { status: "route_error"; context: HookRouteContext; reason: string };
38
+
39
+ interface HookBindingRegistry {
40
+ version: 1;
41
+ bindings: HookBinding[];
42
+ }
43
+
44
+ export interface HookRegistryLockReport {
45
+ path: string;
46
+ exists: boolean;
47
+ stale: boolean;
48
+ ageMs?: number;
49
+ pid?: number;
50
+ processAlive?: boolean;
51
+ }
52
+
53
+ export interface UpsertHookBindingInput {
54
+ repoRoot: string;
55
+ runId?: string;
56
+ sessionId?: string;
57
+ transcriptPath?: string;
58
+ status?: HookBinding["status"];
59
+ }
60
+
61
+ export function hookRegistryPath(codexHome = codexHomePath()): string {
62
+ return join(codexHome, "agent-loop", "hook-bindings.json");
63
+ }
64
+
65
+ export function hookRegistryLockPath(codexHome = codexHomePath()): string {
66
+ return `${hookRegistryPath(codexHome)}.lock`;
67
+ }
68
+
69
+ export function codexHomePath(): string {
70
+ return process.env.CODEX_HOME ?? join(homedir(), ".codex");
71
+ }
72
+
73
+ export function listHookBindings(codexHome = codexHomePath()): HookBinding[] {
74
+ return readRegistry(codexHome).bindings;
75
+ }
76
+
77
+ export function inspectHookRegistryLock(codexHome = codexHomePath()): HookRegistryLockReport {
78
+ const path = hookRegistryLockPath(codexHome);
79
+ if (!existsSync(path)) {
80
+ return { path, exists: false, stale: false };
81
+ }
82
+ const metadata = readLockMetadata(path);
83
+ const stat = statSync(path);
84
+ const ageMs = Date.now() - (metadata.createdAtMs ?? stat.mtimeMs);
85
+ const alive = metadata.pid ? processAlive(metadata.pid) : undefined;
86
+ return {
87
+ path,
88
+ exists: true,
89
+ stale: ageMs > LOCK_STALE_MS && alive !== true,
90
+ ageMs,
91
+ ...(metadata.pid ? { pid: metadata.pid } : {}),
92
+ ...(alive === undefined ? {} : { processAlive: alive })
93
+ };
94
+ }
95
+
96
+ /** Create or update an active hook binding for one repo/worktree/session. */
97
+ export function upsertHookBinding(input: UpsertHookBindingInput, codexHome = codexHomePath()): HookBinding {
98
+ return withRegistryLock(codexHome, () => {
99
+ const repoRoot = canonicalPath(input.repoRoot);
100
+ const context = resolveHookContext({ cwd: repoRoot, sessionId: input.sessionId, transcriptPath: input.transcriptPath });
101
+ const sessionIdHash = input.sessionId ? sha256(input.sessionId) : undefined;
102
+ const now = new Date().toISOString();
103
+ const registry = readRegistry(codexHome);
104
+ const worktreeBindings = registry.bindings.filter((binding) => binding.worktreeRoot === context.worktreeRoot);
105
+ const exact = registry.bindings.find((binding) =>
106
+ binding.worktreeRoot === context.worktreeRoot &&
107
+ (binding.sessionIdHash ?? "") === (sessionIdHash ?? "")
108
+ );
109
+ const singleScoped = sessionIdHash === undefined
110
+ ? worktreeBindings.filter((binding) => binding.sessionIdHash !== undefined)
111
+ : [];
112
+ const existing = exact ?? (singleScoped.length === 1 ? singleScoped[0] : undefined);
113
+ const binding: HookBinding = {
114
+ id: existing?.id ?? randomUUID(),
115
+ repoRoot,
116
+ worktreeRoot: context.worktreeRoot,
117
+ ...(context.gitCommonDir ? { gitCommonDir: context.gitCommonDir } : {}),
118
+ ...(context.branch ? { branch: context.branch } : {}),
119
+ ...(input.runId ? { runId: input.runId } : existing?.runId ? { runId: existing.runId } : {}),
120
+ ...(sessionIdHash ? { sessionIdHash } : existing?.sessionIdHash ? { sessionIdHash: existing.sessionIdHash } : {}),
121
+ ...(input.transcriptPath ? { transcriptPathSha256: sha256(input.transcriptPath) } : existing?.transcriptPathSha256 ? { transcriptPathSha256: existing.transcriptPathSha256 } : {}),
122
+ status: input.status ?? "active",
123
+ createdAt: existing?.createdAt ?? now,
124
+ updatedAt: now,
125
+ ...(existing?.lastSeenAt ? { lastSeenAt: existing.lastSeenAt } : {})
126
+ };
127
+ registry.bindings = [
128
+ ...registry.bindings.filter((item) => item.id !== binding.id && !(sessionIdHash && item.worktreeRoot === context.worktreeRoot && item.sessionIdHash === undefined)),
129
+ binding
130
+ ];
131
+ writeRegistry(registry, codexHome);
132
+ return binding;
133
+ });
134
+ }
135
+
136
+ export function removeHookBinding(input: { repoRoot: string; sessionId?: string }, codexHome = codexHomePath()): HookBinding[] {
137
+ return withRegistryLock(codexHome, () => {
138
+ const repoRoot = canonicalPath(input.repoRoot);
139
+ const context = resolveHookContext({ cwd: repoRoot, sessionId: input.sessionId });
140
+ const sessionIdHash = input.sessionId ? sha256(input.sessionId) : undefined;
141
+ const registry = readRegistry(codexHome);
142
+ const removed = registry.bindings.filter((binding) =>
143
+ binding.worktreeRoot === context.worktreeRoot &&
144
+ (sessionIdHash === undefined || binding.sessionIdHash === sessionIdHash)
145
+ );
146
+ registry.bindings = registry.bindings.filter((binding) => !removed.some((item) => item.id === binding.id));
147
+ writeRegistry(registry, codexHome);
148
+ return removed;
149
+ });
150
+ }
151
+
152
+ /** Resolve a hook payload to exactly one binding, or report why routing cannot safely proceed. */
153
+ export function resolveHookRoute(payload: unknown, options: { legacyRepoRoot?: string | undefined; codexHome?: string | undefined } = {}): HookRouteResult {
154
+ const context = hookContextFromPayload(payload, options.legacyRepoRoot);
155
+ let registry: HookBindingRegistry;
156
+ try {
157
+ registry = readRegistry(options.codexHome ?? codexHomePath());
158
+ } catch (error) {
159
+ return { status: "route_error", context, reason: error instanceof Error ? error.message : String(error) };
160
+ }
161
+ try {
162
+ const active = registry.bindings.filter((binding) => binding.status === "active");
163
+ const worktreeMatches = active.filter((binding) => bindingMatchesContext(binding, context));
164
+ const contextSessionHash = context.sessionId ? sha256(context.sessionId) : undefined;
165
+ const sessionMatches = context.sessionId
166
+ ? worktreeMatches.filter((binding) => binding.sessionIdHash === contextSessionHash)
167
+ : [];
168
+ const candidates = sessionMatches.length > 0
169
+ ? sessionMatches
170
+ : worktreeMatches.filter((binding) => binding.sessionIdHash === undefined);
171
+
172
+ if (candidates.length === 1) {
173
+ const binding = touchBinding(candidates[0]!, context, options.codexHome);
174
+ if (contextSessionHash && binding.sessionIdHash !== undefined && binding.sessionIdHash !== contextSessionHash) {
175
+ return { status: "no_match", context, reason: "Hook binding was claimed by another Codex session.", worktreeBinding: true };
176
+ }
177
+ return { status: "matched", binding, context, legacy: false };
178
+ }
179
+ if (candidates.length > 1) {
180
+ return { status: "ambiguous", context, bindings: candidates, reason: "Multiple hook bindings match this Codex session context." };
181
+ }
182
+ if (worktreeMatches.length > 0) {
183
+ return { status: "no_match", context, reason: "Active hook bindings exist for this worktree, but none match this Codex session.", worktreeBinding: true };
184
+ }
185
+
186
+ const legacy = legacyRoute(options.legacyRepoRoot, context);
187
+ if (legacy) {
188
+ return { status: "matched", binding: legacy, context, legacy: true };
189
+ }
190
+ return { status: "no_match", context, reason: "No active agent-loop hook binding matches this Codex session context." };
191
+ } catch (error) {
192
+ return { status: "route_error", context, reason: error instanceof Error ? error.message : String(error) };
193
+ }
194
+ }
195
+
196
+ export function hookContextFromPayload(payload: unknown, fallbackCwd = process.cwd()): HookRouteContext {
197
+ const record = isRecord(payload) ? payload : {};
198
+ return resolveHookContext({
199
+ cwd: stringValue(record.cwd) ?? fallbackCwd,
200
+ sessionId: stringValue(record.session_id) ?? stringValue(record.sessionId),
201
+ turnId: stringValue(record.turn_id) ?? stringValue(record.turnId),
202
+ transcriptPath: stringValue(record.transcript_path) ?? stringValue(record.transcriptPath)
203
+ });
204
+ }
205
+
206
+ export function resolveHookContext(input: { cwd: string; sessionId?: string | undefined; turnId?: string | undefined; transcriptPath?: string | undefined }): HookRouteContext {
207
+ const cwd = canonicalPath(input.cwd);
208
+ const worktreeRoot = gitOutput(["rev-parse", "--show-toplevel"], cwd);
209
+ const commonDir = gitOutput(["rev-parse", "--git-common-dir"], cwd);
210
+ const branch = gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
211
+ const commonPath = commonDir
212
+ ? canonicalPath(isAbsolute(commonDir) ? commonDir : join(cwd, commonDir))
213
+ : undefined;
214
+ return {
215
+ cwd,
216
+ worktreeRoot: worktreeRoot ? canonicalPath(worktreeRoot) : cwd,
217
+ ...(commonPath ? { gitCommonDir: commonPath } : {}),
218
+ ...(branch && branch !== "HEAD" ? { branch } : {}),
219
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
220
+ ...(input.turnId ? { turnId: input.turnId } : {}),
221
+ ...(input.transcriptPath ? { transcriptPathSha256: sha256(input.transcriptPath) } : {})
222
+ };
223
+ }
224
+
225
+ function readRegistry(codexHome: string): HookBindingRegistry {
226
+ const path = hookRegistryPath(codexHome);
227
+ if (!existsSync(path)) {
228
+ return { version: 1, bindings: [] };
229
+ }
230
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
231
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.bindings)) {
232
+ throw new Error(`Invalid hook binding registry: expected { version: 1, bindings: [...] } in ${path}`);
233
+ }
234
+ const bindings = parsed.bindings.map(parseBinding);
235
+ const invalid = bindings.findIndex((binding) => binding === undefined);
236
+ if (invalid >= 0) {
237
+ throw new Error(`Invalid hook binding registry: invalid binding at index ${invalid} in ${path}`);
238
+ }
239
+ return {
240
+ version: 1,
241
+ bindings: bindings.filter((binding): binding is HookBinding => binding !== undefined)
242
+ };
243
+ }
244
+
245
+ function writeRegistry(registry: HookBindingRegistry, codexHome: string): void {
246
+ const path = hookRegistryPath(codexHome);
247
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
248
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
249
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
250
+ renameSync(tmp, path);
251
+ }
252
+
253
+ function parseBinding(value: unknown): HookBinding | undefined {
254
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.repoRoot !== "string" || typeof value.worktreeRoot !== "string") {
255
+ return undefined;
256
+ }
257
+ const status = value.status === "stale" || value.status === "disabled" ? value.status : "active";
258
+ if (typeof value.createdAt !== "string" || typeof value.updatedAt !== "string") {
259
+ return undefined;
260
+ }
261
+ return {
262
+ id: value.id,
263
+ repoRoot: value.repoRoot,
264
+ worktreeRoot: value.worktreeRoot,
265
+ ...(typeof value.gitCommonDir === "string" ? { gitCommonDir: value.gitCommonDir } : {}),
266
+ ...(typeof value.branch === "string" ? { branch: value.branch } : {}),
267
+ ...(typeof value.runId === "string" ? { runId: value.runId } : {}),
268
+ ...(typeof value.sessionIdHash === "string" ? { sessionIdHash: value.sessionIdHash } : typeof value.sessionId === "string" ? { sessionIdHash: sha256(value.sessionId) } : {}),
269
+ ...(typeof value.transcriptPathSha256 === "string" ? { transcriptPathSha256: value.transcriptPathSha256 } : {}),
270
+ status,
271
+ createdAt: value.createdAt,
272
+ updatedAt: value.updatedAt,
273
+ ...(typeof value.lastSeenAt === "string" ? { lastSeenAt: value.lastSeenAt } : {})
274
+ };
275
+ }
276
+
277
+ function touchBinding(binding: HookBinding, context: HookRouteContext, codexHome = codexHomePath()): HookBinding {
278
+ return withRegistryLock(codexHome, () => {
279
+ const registry = readRegistry(codexHome);
280
+ const current = registry.bindings.find((item) => item.id === binding.id) ?? binding;
281
+ const contextSessionHash = context.sessionId ? sha256(context.sessionId) : undefined;
282
+ if (current.sessionIdHash !== undefined && contextSessionHash !== undefined && current.sessionIdHash !== contextSessionHash) {
283
+ return current;
284
+ }
285
+ const nowMs = Date.now();
286
+ const shouldClaimSession = current.sessionIdHash === undefined && contextSessionHash !== undefined;
287
+ const shouldClaimTranscript = current.transcriptPathSha256 === undefined && context.transcriptPathSha256 !== undefined;
288
+ const lastSeenAtMs = current.lastSeenAt ? Date.parse(current.lastSeenAt) : 0;
289
+ const shouldRefreshLastSeen = !Number.isFinite(lastSeenAtMs) || nowMs - lastSeenAtMs > TOUCH_REFRESH_MS;
290
+ if (!shouldClaimSession && !shouldClaimTranscript && !shouldRefreshLastSeen) {
291
+ return current;
292
+ }
293
+ const now = new Date(nowMs).toISOString();
294
+ const updated: HookBinding = {
295
+ ...current,
296
+ ...(shouldClaimSession ? { sessionIdHash: contextSessionHash } : {}),
297
+ ...(shouldClaimTranscript ? { transcriptPathSha256: context.transcriptPathSha256 } : {}),
298
+ lastSeenAt: now,
299
+ updatedAt: now
300
+ };
301
+ registry.bindings = registry.bindings.map((item) => item.id === current.id ? updated : item);
302
+ writeRegistry(registry, codexHome);
303
+ return updated;
304
+ });
305
+ }
306
+
307
+ function legacyRoute(legacyRepoRoot: string | undefined, context: HookRouteContext): HookBinding | undefined {
308
+ if (!legacyRepoRoot) return undefined;
309
+ const legacyContext = resolveHookContext({ cwd: legacyRepoRoot });
310
+ if (legacyContext.worktreeRoot !== context.worktreeRoot) {
311
+ return undefined;
312
+ }
313
+ const now = new Date().toISOString();
314
+ return {
315
+ id: `legacy:${sha256(legacyContext.worktreeRoot).slice(0, 16)}`,
316
+ repoRoot: canonicalPath(legacyRepoRoot),
317
+ worktreeRoot: legacyContext.worktreeRoot,
318
+ ...(legacyContext.gitCommonDir ? { gitCommonDir: legacyContext.gitCommonDir } : {}),
319
+ ...(legacyContext.branch ? { branch: legacyContext.branch } : {}),
320
+ status: "active",
321
+ createdAt: now,
322
+ updatedAt: now
323
+ };
324
+ }
325
+
326
+ function bindingMatchesContext(binding: HookBinding, context: HookRouteContext): boolean {
327
+ if (binding.worktreeRoot === context.worktreeRoot) {
328
+ return true;
329
+ }
330
+ return binding.gitCommonDir !== undefined &&
331
+ context.gitCommonDir !== undefined &&
332
+ binding.gitCommonDir === context.gitCommonDir &&
333
+ context.cwd.startsWith(`${binding.worktreeRoot}/`);
334
+ }
335
+
336
+ function canonicalPath(path: string): string {
337
+ const resolved = resolve(path);
338
+ return existsSync(resolved) ? realpathSync(resolved) : resolved;
339
+ }
340
+
341
+ function gitOutput(args: string[], cwd: string): string | undefined {
342
+ try {
343
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
344
+ } catch {
345
+ return undefined;
346
+ }
347
+ }
348
+
349
+ function sha256(value: string): string {
350
+ return createHash("sha256").update(value).digest("hex");
351
+ }
352
+
353
+ function withRegistryLock<T>(codexHome: string, fn: () => T): T {
354
+ const path = hookRegistryPath(codexHome);
355
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
356
+ const lockPath = hookRegistryLockPath(codexHome);
357
+ let fd: number | undefined;
358
+ for (let attempt = 0; attempt < 100; attempt += 1) {
359
+ try {
360
+ fd = openSync(lockPath, "wx", 0o600);
361
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`);
362
+ break;
363
+ } catch (error) {
364
+ if (typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EEXIST") {
365
+ if (recoverStaleLock(lockPath)) {
366
+ continue;
367
+ }
368
+ sleepSync(20);
369
+ continue;
370
+ }
371
+ throw error;
372
+ }
373
+ }
374
+ if (fd === undefined) {
375
+ throw new Error(`Timed out waiting for hook registry lock: ${lockPath}`);
376
+ }
377
+ try {
378
+ return fn();
379
+ } finally {
380
+ closeSync(fd);
381
+ rmSync(lockPath, { force: true });
382
+ }
383
+ }
384
+
385
+ const LOCK_STALE_MS = 30_000;
386
+ const TOUCH_REFRESH_MS = 10_000;
387
+
388
+ function recoverStaleLock(lockPath: string): boolean {
389
+ const report = inspectLockPath(lockPath);
390
+ if (!report.stale) {
391
+ return false;
392
+ }
393
+ rmSync(lockPath, { force: true });
394
+ return true;
395
+ }
396
+
397
+ function inspectLockPath(path: string): HookRegistryLockReport {
398
+ if (!existsSync(path)) {
399
+ return { path, exists: false, stale: false };
400
+ }
401
+ const metadata = readLockMetadata(path);
402
+ const stat = statSync(path);
403
+ const ageMs = Date.now() - (metadata.createdAtMs ?? stat.mtimeMs);
404
+ const alive = metadata.pid ? processAlive(metadata.pid) : undefined;
405
+ return {
406
+ path,
407
+ exists: true,
408
+ stale: ageMs > LOCK_STALE_MS && alive !== true,
409
+ ageMs,
410
+ ...(metadata.pid ? { pid: metadata.pid } : {}),
411
+ ...(alive === undefined ? {} : { processAlive: alive })
412
+ };
413
+ }
414
+
415
+ function readLockMetadata(path: string): { pid?: number; createdAtMs?: number } {
416
+ try {
417
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
418
+ if (!isRecord(parsed)) return {};
419
+ const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
420
+ const createdAtMs = typeof parsed.createdAt === "string" ? Date.parse(parsed.createdAt) : undefined;
421
+ return {
422
+ ...(pid && Number.isInteger(pid) && pid > 0 ? { pid } : {}),
423
+ ...(createdAtMs && Number.isFinite(createdAtMs) ? { createdAtMs } : {})
424
+ };
425
+ } catch {
426
+ return {};
427
+ }
428
+ }
429
+
430
+ function processAlive(pid: number): boolean {
431
+ try {
432
+ process.kill(pid, 0);
433
+ return true;
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+
439
+ function sleepSync(ms: number): void {
440
+ try {
441
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
442
+ } catch {
443
+ const end = Date.now() + ms;
444
+ while (Date.now() < end) {
445
+ // Fallback for hardened runtimes without SharedArrayBuffer.
446
+ }
447
+ }
448
+ }
449
+
450
+ function stringValue(value: unknown): string | undefined {
451
+ return typeof value === "string" && value.length > 0 ? value : undefined;
452
+ }
@@ -0,0 +1,32 @@
1
+ export * from "./cli.js";
2
+ export * from "./command.js";
3
+ export * from "./command-runner.js";
4
+ export * from "./config.js";
5
+ export * from "./dashboard-server.js";
6
+ export * from "./doctor.js";
7
+ export * from "./errors.js";
8
+ export * from "./gates.js";
9
+ export * from "./git.js";
10
+ export * from "./github.js";
11
+ export * from "./hook-policy.js";
12
+ export * from "./locale.js";
13
+ export * from "./gitnexus.js";
14
+ export * from "./mcp-controller.js";
15
+ export * from "./policy.js";
16
+ export * from "./ci.js";
17
+ export * from "./review-comments.js";
18
+ export * from "./pr-lifecycle.js";
19
+ export * from "./artifacts.js";
20
+ export * from "./autonomy-policy.js";
21
+ export * from "./config-editor.js";
22
+ export * from "./state-machine.js";
23
+ export * from "./state-types.js";
24
+ export * from "./storage.js";
25
+ export * from "./types.js";
26
+ export * from "./gate-recovery.js";
27
+ export * from "./notification-feed.js";
28
+ export * from "./plan-parser.js";
29
+ export * from "./scope-guard.js";
30
+ export * from "./worker.js";
31
+ export * from "./worker-events.js";
32
+ export * from "./worker-prompts.js";