@logicseek/hello-cc 0.1.5 → 0.1.7

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 (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
package/lib/db-schema.mjs CHANGED
@@ -1,324 +1,7 @@
1
- import { CliError } from './errors.mjs';
2
-
3
- export const DB_SCHEMA_VERSION = 5;
4
-
5
- const sleepBuffer = new SharedArrayBuffer(4);
6
- const sleepView = new Int32Array(sleepBuffer);
7
-
8
- function now() {
9
- return Math.floor(Date.now() / 1000);
10
- }
11
-
12
- function sleepSync(ms) {
13
- Atomics.wait(sleepView, 0, 0, ms);
14
- }
15
-
16
- function isSqliteBusy(err) {
17
- const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
18
- return err?.errcode === 5 ||
19
- err?.errcode === 6 ||
20
- err?.errcode === 261 ||
21
- /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
22
- }
23
-
24
- export function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
25
- for (let attempt = 0; attempt < attempts; attempt += 1) {
26
- try {
27
- db.exec(sql);
28
- return true;
29
- } catch (err) {
30
- if (!isSqliteBusy(err)) throw err;
31
- if (attempt === attempts - 1) {
32
- if (ignoreBusy) return false;
33
- throw err;
34
- }
35
- sleepSync(delayMs);
36
- }
37
- }
38
- return false;
39
- }
40
-
41
- export function tx(db, fn) {
42
- execWithBusyRetry(db, 'BEGIN IMMEDIATE;');
43
- try {
44
- const result = fn();
45
- db.exec('COMMIT;');
46
- return result;
47
- } catch (err) {
48
- try {
49
- db.exec('ROLLBACK;');
50
- } catch {
51
- // Ignore rollback failures; the original error is more useful.
52
- }
53
- throw err;
54
- }
55
- }
56
-
57
- export function initSchema(db, { beforePostMigrationIndexes = null } = {}) {
58
- const existingVersion = readSchemaVersion(db);
59
- if (existingVersion > DB_SCHEMA_VERSION) {
60
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
61
- db_schema_version: existingVersion,
62
- supported_schema_version: DB_SCHEMA_VERSION
63
- });
64
- }
65
- createBaseSchema(db);
66
- runSchemaMigrations(db);
67
- if (typeof beforePostMigrationIndexes === 'function') beforePostMigrationIndexes(db);
68
- createPostMigrationIndexes(db);
69
- }
70
-
71
- function createBaseSchema(db) {
72
- execWithBusyRetry(db, `
73
- CREATE TABLE IF NOT EXISTS meta (
74
- key TEXT PRIMARY KEY,
75
- value TEXT NOT NULL
76
- );
77
-
78
- CREATE TABLE IF NOT EXISTS schema_migrations (
79
- version INTEGER PRIMARY KEY,
80
- name TEXT NOT NULL,
81
- applied_at INTEGER NOT NULL
82
- );
83
-
84
- CREATE TABLE IF NOT EXISTS peers (
85
- id TEXT PRIMARY KEY,
86
- kind TEXT NOT NULL,
87
- role TEXT,
88
- worktree TEXT,
89
- branch TEXT,
90
- pid INTEGER,
91
- status TEXT NOT NULL DEFAULT 'idle',
92
- capabilities TEXT,
93
- created_at INTEGER NOT NULL,
94
- last_seen_at INTEGER NOT NULL
95
- );
96
-
97
- CREATE TABLE IF NOT EXISTS peer_bindings (
98
- peer TEXT PRIMARY KEY,
99
- provider TEXT NOT NULL,
100
- provider_session_id TEXT,
101
- provider_session_name TEXT,
102
- resume_mode TEXT NOT NULL DEFAULT 'new',
103
- resume_arg TEXT,
104
- command TEXT,
105
- transport TEXT NOT NULL,
106
- runtime_session_id TEXT,
107
- runtime_target TEXT,
108
- created_at INTEGER NOT NULL,
109
- updated_at INTEGER NOT NULL,
110
- FOREIGN KEY (peer) REFERENCES peers(id) ON DELETE CASCADE
111
- );
112
-
113
- CREATE TABLE IF NOT EXISTS tasks (
114
- id INTEGER PRIMARY KEY AUTOINCREMENT,
115
- title TEXT NOT NULL,
116
- body TEXT,
117
- status TEXT NOT NULL DEFAULT 'pending',
118
- assignee TEXT,
119
- owner TEXT,
120
- parent_id INTEGER,
121
- team_role TEXT,
122
- priority INTEGER NOT NULL DEFAULT 100,
123
- created_by TEXT,
124
- claimed_at INTEGER,
125
- completed_at INTEGER,
126
- created_at INTEGER NOT NULL,
127
- updated_at INTEGER NOT NULL
128
- );
129
-
130
- CREATE TABLE IF NOT EXISTS messages (
131
- id INTEGER PRIMARY KEY AUTOINCREMENT,
132
- sender TEXT NOT NULL,
133
- recipient TEXT,
134
- task_id INTEGER,
135
- kind TEXT NOT NULL DEFAULT 'note',
136
- body TEXT NOT NULL,
137
- reply_to INTEGER,
138
- thread_id INTEGER,
139
- created_at INTEGER NOT NULL
140
- );
141
-
142
- CREATE TABLE IF NOT EXISTS message_reads (
143
- message_id INTEGER NOT NULL,
144
- peer TEXT NOT NULL,
145
- read_at INTEGER NOT NULL,
146
- PRIMARY KEY (message_id, peer),
147
- FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
148
- );
149
-
150
- CREATE TABLE IF NOT EXISTS locks (
151
- resource TEXT PRIMARY KEY,
152
- base_resource TEXT,
153
- scope TEXT NOT NULL DEFAULT '*',
154
- owner TEXT NOT NULL,
155
- task_id INTEGER,
156
- reason TEXT,
157
- expires_at INTEGER NOT NULL,
158
- created_at INTEGER NOT NULL
159
- );
160
-
161
- CREATE TABLE IF NOT EXISTS handoffs (
162
- id INTEGER PRIMARY KEY AUTOINCREMENT,
163
- task_id INTEGER,
164
- from_peer TEXT NOT NULL,
165
- to_peer TEXT,
166
- summary TEXT NOT NULL,
167
- changed_files TEXT,
168
- tests TEXT,
169
- risks TEXT,
170
- created_at INTEGER NOT NULL
171
- );
172
-
173
- CREATE TABLE IF NOT EXISTS events (
174
- id INTEGER PRIMARY KEY AUTOINCREMENT,
175
- type TEXT NOT NULL,
176
- actor TEXT,
177
- task_id INTEGER,
178
- payload TEXT,
179
- created_at INTEGER NOT NULL
180
- );
181
-
182
- CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority, id);
183
- CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
184
- CREATE INDEX IF NOT EXISTS idx_messages_recipient_id ON messages(recipient, id);
185
- CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
186
- CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
187
- `);
188
- }
189
-
190
- function createPostMigrationIndexes(db) {
191
- execWithBusyRetry(db, `
192
- CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
193
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
194
- ON peer_bindings(provider, provider_session_id)
195
- WHERE provider_session_id IS NOT NULL;
196
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
197
- ON peer_bindings(provider, provider_session_name)
198
- WHERE provider_session_name IS NOT NULL;
199
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
200
- ON peer_bindings(runtime_target)
201
- WHERE runtime_target IS NOT NULL;
202
- `);
203
- }
204
-
205
- function tableColumns(db, tableName) {
206
- return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
207
- }
208
-
209
- function tableExists(db, tableName) {
210
- return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
211
- }
212
-
213
- export function readSchemaVersion(db) {
214
- const metaExists = tableExists(db, 'meta');
215
- const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
216
- const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
217
- const migrationVersion = tableExists(db, 'schema_migrations')
218
- ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
219
- : 0;
220
- return Math.max(metaVersion, pragmaVersion, migrationVersion);
221
- }
222
-
223
- function writeSchemaVersion(db, version) {
224
- db.prepare(`
225
- INSERT INTO meta(key, value) VALUES ('schema_version', ?)
226
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
227
- `).run(String(version));
228
- db.exec(`PRAGMA user_version = ${Number(version)}`);
229
- }
230
-
231
- function recordSchemaMigration(db, version, name) {
232
- db.prepare(`
233
- INSERT INTO schema_migrations(version, name, applied_at)
234
- VALUES (?, ?, ?)
235
- ON CONFLICT(version) DO NOTHING
236
- `).run(version, name, now());
237
- }
238
-
239
- function addColumnIfMissing(db, tableName, columnName, ddl) {
240
- const columns = tableColumns(db, tableName);
241
- if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
242
- }
243
-
244
- function runSchemaMigrations(db) {
245
- const migrations = [
246
- {
247
- version: 1,
248
- name: 'baseline',
249
- up() {
250
- // createBaseSchema already created the baseline tables.
251
- }
252
- },
253
- {
254
- version: 2,
255
- name: 'peer binding runtime targets',
256
- up(database) {
257
- addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
258
- }
259
- },
260
- {
261
- version: 3,
262
- name: 'threaded messages',
263
- up(database) {
264
- addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
265
- addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
266
- database.exec(`
267
- UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
268
- CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
269
- `);
270
- }
271
- },
272
- {
273
- version: 4,
274
- name: 'team task hierarchy',
275
- up(database) {
276
- addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
277
- addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
278
- database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
279
- }
280
- },
281
- {
282
- version: 5,
283
- name: 'scoped advisory locks',
284
- up(database) {
285
- addColumnIfMissing(database, 'locks', 'base_resource', 'base_resource TEXT');
286
- addColumnIfMissing(database, 'locks', 'scope', "scope TEXT NOT NULL DEFAULT '*'");
287
- database.exec(`
288
- UPDATE locks
289
- SET base_resource = resource
290
- WHERE base_resource IS NULL OR base_resource = '';
291
- UPDATE locks
292
- SET scope = '*'
293
- WHERE scope IS NULL OR scope = '';
294
- CREATE INDEX IF NOT EXISTS idx_locks_base_scope ON locks(base_resource, scope);
295
- `);
296
- }
297
- }
298
- ];
299
- let version = readSchemaVersion(db);
300
- if (version > DB_SCHEMA_VERSION) {
301
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
302
- db_schema_version: version,
303
- supported_schema_version: DB_SCHEMA_VERSION
304
- });
305
- }
306
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
307
- tx(db, () => {
308
- for (const migration of migrations) {
309
- if (version >= migration.version && applied.has(migration.version)) continue;
310
- migration.up(db);
311
- recordSchemaMigration(db, migration.version, migration.name);
312
- applied.add(migration.version);
313
- version = migration.version;
314
- writeSchemaVersion(db, version);
315
- }
316
- if (version === 0) {
317
- version = DB_SCHEMA_VERSION;
318
- }
319
- for (const migration of migrations) {
320
- if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
321
- }
322
- writeSchemaVersion(db, version);
323
- });
324
- }
1
+ export {
2
+ DB_SCHEMA_VERSION,
3
+ execWithBusyRetry,
4
+ initSchema,
5
+ readSchemaVersion,
6
+ tx
7
+ } from './db/schema.mjs';
package/lib/errors.mjs CHANGED
@@ -1,7 +1 @@
1
- export class CliError extends Error {
2
- constructor(code, message, extra = {}) {
3
- super(message);
4
- this.code = code;
5
- this.extra = extra;
6
- }
7
- }
1
+ export { CliError } from './shared/errors.mjs';
@@ -0,0 +1,26 @@
1
+ import { runGit } from './runtime/project-context.mjs';
2
+
3
+ export function normalizeListText(value, fallback = []) {
4
+ if (value === undefined || value === null || value === '') return JSON.stringify(fallback);
5
+ const text = String(value);
6
+ try {
7
+ JSON.parse(text);
8
+ return text;
9
+ } catch {
10
+ return JSON.stringify(text.split(',').map((item) => item.trim()).filter(Boolean));
11
+ }
12
+ }
13
+
14
+ export function changedFiles(cwd) {
15
+ const unstaged = runGit(['diff', '--name-only'], cwd);
16
+ const staged = runGit(['diff', '--cached', '--name-only'], cwd);
17
+ const files = new Set();
18
+ for (const block of [unstaged, staged]) {
19
+ if (!block) continue;
20
+ for (const line of block.split('\n')) {
21
+ const trimmed = line.trim();
22
+ if (trimmed) files.add(trimmed);
23
+ }
24
+ }
25
+ return [...files].sort();
26
+ }
package/lib/help.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export {
2
+ createHelpFunctions
3
+ } from './ui/help.mjs';
@@ -0,0 +1,171 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readJsonSafe, writeJsonSafe } from '../shared/json-file.mjs';
4
+
5
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
6
+ const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
7
+
8
+ /**
9
+ * Hook events we install handlers for.
10
+ * - SessionStart : Inject initial coordination context
11
+ * - UserPromptSubmit : Inject fresh coordination context before the model answers
12
+ * - Stop : Claude goes idle after a turn -> deliver inbox messages
13
+ * - PostToolUse : After every tool call -> heartbeat + inbox check
14
+ * - PreToolUse : Before every tool call -> register peer if not yet registered
15
+ */
16
+ const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse'];
17
+ const CODEX_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
18
+
19
+ /**
20
+ * Install (or update) hello-cc hooks in ~/.claude/settings.json.
21
+ * Non-destructive: only adds hcc entries, never removes existing ones.
22
+ * Returns the settings file path.
23
+ */
24
+ export function installClaudeHooks(hccBin) {
25
+ const settings = readJsonSafe(CLAUDE_SETTINGS_PATH) || {};
26
+
27
+ if (!settings.hooks) settings.hooks = {};
28
+
29
+ const hookCmd = `${hccBin} hook`;
30
+
31
+ for (const event of CLAUDE_HOOK_EVENTS) {
32
+ settings.hooks[event] = mergeHookEntry(
33
+ settings.hooks[event],
34
+ `${hookCmd} ${event.toLowerCase()}`
35
+ );
36
+ }
37
+
38
+ writeJsonSafe(CLAUDE_SETTINGS_PATH, settings);
39
+ return CLAUDE_SETTINGS_PATH;
40
+ }
41
+
42
+ /**
43
+ * Remove hello-cc hook entries from ~/.claude/settings.json.
44
+ */
45
+ export function uninstallClaudeHooks() {
46
+ const settings = readJsonSafe(CLAUDE_SETTINGS_PATH);
47
+ if (!settings?.hooks) return false;
48
+
49
+ for (const event of Object.keys(settings.hooks)) {
50
+ if (!Array.isArray(settings.hooks[event])) continue;
51
+ settings.hooks[event] = settings.hooks[event].filter(entry => {
52
+ if (!Array.isArray(entry?.hooks)) return true;
53
+ return !entry.hooks.some(h => isHccHookCmd(h?.command));
54
+ });
55
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
56
+ }
57
+
58
+ writeJsonSafe(CLAUDE_SETTINGS_PATH, settings);
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Returns true if hello-cc hooks are present in ~/.claude/settings.json.
64
+ */
65
+ export function verifyClaudeHooks() {
66
+ const settings = readJsonSafe(CLAUDE_SETTINGS_PATH);
67
+ if (!settings?.hooks) return false;
68
+ return CLAUDE_HOOK_EVENTS.every(event => hasHookEntry(settings.hooks[event]));
69
+ }
70
+
71
+ /**
72
+ * Install hello-cc hooks in ~/.codex/hooks.json.
73
+ *
74
+ * Codex hooks.json format:
75
+ * { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [...] }], "Stop": [...] } }
76
+ */
77
+ export function installCodexHooks(hccBin) {
78
+ const hooks = readJsonSafe(CODEX_HOOKS_PATH) || {};
79
+
80
+ if (!hooks.hooks) hooks.hooks = {};
81
+
82
+ const hookCmd = `${hccBin} hook`;
83
+
84
+ // SessionStart: inject initial coordination context on startup/resume.
85
+ hooks.hooks.SessionStart = mergeCodexHookEntry(
86
+ hooks.hooks.SessionStart,
87
+ `${hookCmd} sessionstart`,
88
+ null
89
+ );
90
+ // UserPromptSubmit: inject fresh coordination context before each model turn.
91
+ hooks.hooks.UserPromptSubmit = mergeCodexHookEntry(
92
+ hooks.hooks.UserPromptSubmit,
93
+ `${hookCmd} userpromptsubmit`,
94
+ null
95
+ );
96
+ // PreToolUse: fires before every Bash tool call - heartbeat + peer registration
97
+ hooks.hooks.PreToolUse = mergeCodexHookEntry(
98
+ hooks.hooks.PreToolUse,
99
+ `${hookCmd} pretooluse`,
100
+ 'Bash'
101
+ );
102
+ // Stop: fires when Codex goes idle - deliver inbox messages
103
+ hooks.hooks.Stop = mergeCodexHookEntry(
104
+ hooks.hooks.Stop,
105
+ `${hookCmd} stop`,
106
+ null
107
+ );
108
+
109
+ writeJsonSafe(CODEX_HOOKS_PATH, hooks);
110
+ return CODEX_HOOKS_PATH;
111
+ }
112
+
113
+ export function uninstallCodexHooks() {
114
+ const hooks = readJsonSafe(CODEX_HOOKS_PATH);
115
+ if (!hooks?.hooks) return false;
116
+
117
+ for (const event of Object.keys(hooks.hooks)) {
118
+ if (!Array.isArray(hooks.hooks[event])) continue;
119
+ hooks.hooks[event] = hooks.hooks[event].filter(entry => {
120
+ if (!Array.isArray(entry?.hooks)) return true;
121
+ return !entry.hooks.some(h => isHccHookCmd(h?.command));
122
+ });
123
+ if (hooks.hooks[event].length === 0) delete hooks.hooks[event];
124
+ }
125
+
126
+ writeJsonSafe(CODEX_HOOKS_PATH, hooks);
127
+ return true;
128
+ }
129
+
130
+ export function verifyCodexHooks() {
131
+ const hooks = readJsonSafe(CODEX_HOOKS_PATH);
132
+ if (!hooks?.hooks) return false;
133
+ return CODEX_HOOK_EVENTS.every(event => hasHookEntry(hooks.hooks[event]));
134
+ }
135
+
136
+ function mergeHookEntry(existing, command) {
137
+ const entries = Array.isArray(existing) ? [...existing] : [];
138
+ const alreadyInstalled = entries.some(
139
+ e => Array.isArray(e?.hooks) && e.hooks.some(h => isHccHookCmd(h?.command))
140
+ );
141
+ if (alreadyInstalled) return entries;
142
+ entries.push({ hooks: [{ type: 'command', command }] });
143
+ return entries;
144
+ }
145
+
146
+ function hasHookEntry(entries) {
147
+ if (!Array.isArray(entries)) return false;
148
+ return entries.some(e =>
149
+ Array.isArray(e?.hooks) && e.hooks.some(h => isHccHookCmd(h?.command))
150
+ );
151
+ }
152
+
153
+ function isHccHookCmd(cmd) {
154
+ if (typeof cmd !== 'string') return false;
155
+ return /\bhcc\b.*\bhook\b/.test(cmd) || /hello-cc.*hook/.test(cmd);
156
+ }
157
+
158
+ function mergeCodexHookEntry(existing, command, matcher) {
159
+ const entries = Array.isArray(existing) ? [...existing] : [];
160
+ const alreadyInstalled = entries.some(
161
+ e => Array.isArray(e?.hooks) && e.hooks.some(h => isHccHookCmd(h?.command))
162
+ );
163
+ if (alreadyInstalled) return entries;
164
+
165
+ const entry = {
166
+ hooks: [{ type: 'command', command }],
167
+ };
168
+ if (matcher) entry.matcher = matcher;
169
+ entries.push(entry);
170
+ return entries;
171
+ }