@inetafrica/open-claudia 2.15.0 → 3.0.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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
package/core/config.js CHANGED
@@ -3,21 +3,17 @@
3
3
 
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
- const { execFileSync } = require("child_process");
7
- const CONFIG_DIR = require("../config-dir");
6
+ const { resolveConfigDir, ensureConfigDir } = require("./config-dir");
7
+ const { atomicWriteFileSync } = require("./fsutil");
8
8
  const { truthy: configTruthy } = require("../project-transcripts");
9
9
 
10
10
  const BOT_DIR = path.resolve(__dirname, "..");
11
+ const CONFIG_DIR = resolveConfigDir();
11
12
  const ENV_PATH = path.join(CONFIG_DIR, ".env");
12
13
 
13
- function loadEnv() {
14
- if (!fs.existsSync(ENV_PATH)) {
15
- if (process.env.OPEN_CLAUDIA_TEST === "1") return { ...process.env };
16
- console.error("No .env file found. Run: node setup.js");
17
- process.exit(1);
18
- }
19
- const lines = fs.readFileSync(ENV_PATH, "utf-8").split("\n");
20
- const env = { ...process.env };
14
+ function parseEnvText(text) {
15
+ const env = {};
16
+ const lines = String(text || "").split(/\r?\n/);
21
17
  for (const line of lines) {
22
18
  const idx = line.indexOf("=");
23
19
  if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
@@ -25,8 +21,16 @@ function loadEnv() {
25
21
  return env;
26
22
  }
27
23
 
24
+ function loadEnv(envPath = ENV_PATH, processEnvironment = process.env) {
25
+ const fileEnv = fs.existsSync(envPath)
26
+ ? parseEnvText(fs.readFileSync(envPath, "utf-8"))
27
+ : {};
28
+ return { ...processEnvironment, ...fileEnv };
29
+ }
30
+
28
31
  function saveEnvKey(key, value) {
29
- const content = fs.readFileSync(ENV_PATH, "utf-8");
32
+ ensureConfigDir(CONFIG_DIR);
33
+ const content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf-8") : "";
30
34
  const lines = content.split("\n");
31
35
  let found = false;
32
36
  const updated = lines.map((line) => {
@@ -34,7 +38,7 @@ function saveEnvKey(key, value) {
34
38
  return line;
35
39
  });
36
40
  if (!found) updated.push(`${key}=${value}`);
37
- fs.writeFileSync(ENV_PATH, updated.join("\n"));
41
+ atomicWriteFileSync(ENV_PATH, updated.join("\n"), { backup: true, mode: 0o600 });
38
42
  }
39
43
 
40
44
  const config = loadEnv();
@@ -43,42 +47,73 @@ function hasPathSeparator(value) {
43
47
  return value.includes("/") || value.includes("\\");
44
48
  }
45
49
 
46
- function which(command) {
47
- const lookup = process.platform === "win32" ? "where" : "which";
48
- return execFileSync(lookup, [command], { encoding: "utf-8" })
49
- .split(/\r?\n/)
50
- .map((line) => line.trim())
51
- .filter(Boolean)[0] || null;
50
+ const PROVIDER_COMMANDS = Object.freeze({ claude: "claude", codex: "codex" });
51
+ const DEFAULT_PROVIDER = String(config.DEFAULT_PROVIDER || "").trim().toLowerCase() || null;
52
+ const UTILITY_PROVIDER = String(config.UTILITY_PROVIDER || "active").trim().toLowerCase() || "active";
53
+ const PROVIDER_FALLBACKS = Object.freeze(String(config.PROVIDER_FALLBACKS || "")
54
+ .split(",")
55
+ .map((value) => value.trim().toLowerCase())
56
+ .filter(Boolean));
57
+ const UTILITY_PURPOSE_PROVIDERS = Object.freeze({
58
+ dream: String(config.DREAM_PROVIDER || "").trim().toLowerCase() || null,
59
+ introspection: String(config.DREAM_PROVIDER || "").trim().toLowerCase() || null,
60
+ review: String(config.MEMORY_PROVIDER || "").trim().toLowerCase() || null,
61
+ recall: String(config.RECALL_PROVIDER || "").trim().toLowerCase() || null,
62
+ enforcer: String(config.ENFORCER_PROVIDER || "").trim().toLowerCase() || null,
63
+ subagent: String(config.SUBAGENT_PROVIDER || "").trim().toLowerCase() || null,
64
+ });
65
+
66
+ function configuredProviderPath(providerId) {
67
+ if (providerId === "claude") return String(config.CLAUDE_PATH || "").trim() || null;
68
+ if (providerId === "codex") return String(config.CODEX_PATH || "").trim() || null;
69
+ throw Object.assign(new Error(`Unknown provider: ${providerId}`), { code: "UNKNOWN_PROVIDER" });
52
70
  }
53
71
 
54
- function resolveExecutablePath(configured, fallbackCommand, label, opts = {}) {
55
- const raw = (configured || fallbackCommand || "").trim();
56
- if (!raw) {
57
- if (opts.required) {
58
- console.error(`${label} not set`);
59
- process.exit(1);
72
+ function executableCandidate(command, env = process.env) {
73
+ if (!command) return null;
74
+ if (path.isAbsolute(command) || hasPathSeparator(command)) {
75
+ try {
76
+ fs.accessSync(command, fs.constants.X_OK);
77
+ return path.resolve(command);
78
+ } catch (error) {
79
+ return null;
60
80
  }
61
- return null;
62
81
  }
63
-
64
- if (path.isAbsolute(raw) || hasPathSeparator(raw)) {
65
- if (fs.existsSync(raw)) return raw;
66
- if (opts.required) {
67
- console.error(`${label} not found at: ${raw}`);
68
- process.exit(1);
82
+ const suffixes = process.platform === "win32"
83
+ ? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
84
+ : [""];
85
+ for (const directory of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
86
+ for (const suffix of suffixes) {
87
+ const candidate = path.join(directory, process.platform === "win32" ? command + suffix : command);
88
+ try {
89
+ fs.accessSync(candidate, fs.constants.X_OK);
90
+ return candidate;
91
+ } catch (error) { /* continue */ }
69
92
  }
70
- return null;
71
93
  }
94
+ return null;
95
+ }
72
96
 
73
- try {
74
- return which(raw);
75
- } catch (e) {
76
- if (opts.required) {
77
- console.error(`${label} not found on PATH: ${raw}`);
78
- process.exit(1);
79
- }
80
- return null;
97
+ function resolveExecutablePath(configured, fallbackCommand, _label, _opts = {}) {
98
+ const raw = (configured || fallbackCommand || "").trim();
99
+ return raw ? executableCandidate(raw) : null;
100
+ }
101
+
102
+ function discoverProviderExecutable(providerId, env = process.env) {
103
+ const configured = configuredProviderPath(providerId);
104
+ return executableCandidate(configured || PROVIDER_COMMANDS[providerId], env);
105
+ }
106
+
107
+ function providerExecutableStatus(providerId, env = process.env) {
108
+ const configured = configuredProviderPath(providerId);
109
+ const executable = discoverProviderExecutable(providerId, env);
110
+ if (executable) {
111
+ return { state: "available", executable, configured: !!configured, reason: null };
81
112
  }
113
+ if (configured) {
114
+ return { state: "missing", executable: null, configured: true, reason: `Configured ${providerId} executable was not found` };
115
+ }
116
+ return { state: "unconfigured", executable: null, configured: false, reason: `${providerId} is not configured or installed` };
82
117
  }
83
118
 
84
119
  // Telegram (kept for backwards compat — channels list defaults to telegram)
@@ -86,11 +121,9 @@ const TOKEN = config.TELEGRAM_BOT_TOKEN;
86
121
  const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
87
122
  const CHAT_ID = CHAT_IDS[0];
88
123
 
89
- const WORKSPACE = config.WORKSPACE;
90
- const CLAUDE_PATH = resolveExecutablePath(config.CLAUDE_PATH, null, "Claude CLI", { required: true });
91
- const CURSOR_PATH = config.CURSOR_PATH || null;
124
+ const WORKSPACE = config.WORKSPACE || null;
125
+ const CLAUDE_PATH = configuredProviderPath("claude");
92
126
  const CODEX_PATH = config.CODEX_PATH || null;
93
- const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL || "claude-opus-4-8";
94
127
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
95
128
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
96
129
  const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
@@ -129,31 +162,22 @@ const MAX_VOICE_SIZE = 10 * 1024 * 1024;
129
162
  const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
130
163
  const COMPACT_SUMMARY_TIMEOUT = 10 * 60 * 1000;
131
164
 
132
- if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
165
+ // Compatibility values for the pre-registry runner. They contain only an
166
+ // explicitly configured command; fallback PATH discovery remains lazy through
167
+ // discoverProviderExecutable().
168
+ const resolvedCodexPath = CODEX_PATH;
133
169
 
134
- if (!fs.existsSync(WORKSPACE)) {
135
- try {
136
- fs.mkdirSync(WORKSPACE, { recursive: true });
137
- console.error(`Created workspace: ${WORKSPACE}`);
138
- } catch (e) {
139
- console.error(`Failed to create workspace: ${e.message}`);
140
- process.exit(1);
170
+ function ensureRuntimeDirectories() {
171
+ ensureConfigDir(CONFIG_DIR);
172
+ for (const directory of [WORKSPACE, TEMP_DIR, FILES_DIR]) {
173
+ if (directory) fs.mkdirSync(directory, { recursive: true });
141
174
  }
175
+ return { configDir: CONFIG_DIR, workspace: WORKSPACE, tempDir: TEMP_DIR, filesDir: FILES_DIR };
142
176
  }
143
177
 
144
- let resolvedCursorPath = resolveExecutablePath(CURSOR_PATH, "agent", "Cursor Agent CLI");
145
- if (resolvedCursorPath) console.error(`Cursor Agent CLI: ${resolvedCursorPath}`);
146
-
147
- let resolvedCodexPath = resolveExecutablePath(CODEX_PATH, "codex", "Codex CLI");
148
- if (resolvedCodexPath) console.error(`Codex CLI: ${resolvedCodexPath}`);
149
-
150
- if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
151
- if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
152
-
153
178
  const FULL_PATH = [
154
- path.dirname(CLAUDE_PATH),
155
- resolvedCursorPath ? path.dirname(resolvedCursorPath) : null,
156
- resolvedCodexPath ? path.dirname(resolvedCodexPath) : null,
179
+ CLAUDE_PATH && hasPathSeparator(CLAUDE_PATH) ? path.dirname(CLAUDE_PATH) : null,
180
+ resolvedCodexPath && hasPathSeparator(resolvedCodexPath) ? path.dirname(resolvedCodexPath) : null,
157
181
  path.dirname(process.execPath),
158
182
  FFMPEG ? path.dirname(FFMPEG) : null,
159
183
  WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
@@ -161,7 +185,8 @@ const FULL_PATH = [
161
185
  ? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
162
186
  : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
163
187
  ),
164
- ].filter(Boolean).join(path.delimiter);
188
+ ...String(process.env.PATH || "").split(path.delimiter),
189
+ ].filter(Boolean).filter((value, index, values) => values.indexOf(value) === index).join(path.delimiter);
165
190
 
166
191
  // Kazee runs on one fixed host — like Telegram's api.telegram.org it's
167
192
  // infrastructure, not a per-user setting. Hard-coded so onboarding only asks
@@ -230,7 +255,7 @@ function loadChannels() {
230
255
  }
231
256
 
232
257
  // The bot's own control-plane secrets. No subprocess the bot spawns — the
233
- // Claude/Codex/Cursor CLI, sub-agents, the recall walker, tools, auth flows —
258
+ // Claude/Codex CLIs, sub-agents, the recall walker, tools, and auth flows —
234
259
  // ever needs these: the model CLI is authed via CLAUDE_CODE_OAUTH_TOKEN (added
235
260
  // on top by claudeSubprocessEnv) or its own API key, and `open-claudia` CLI
236
261
  // invocations re-read the .env file from disk. Stripping them from the ambient
@@ -250,21 +275,42 @@ function botSubprocessEnv() {
250
275
  return env;
251
276
  }
252
277
 
278
+ // Source material for provider-specific allowlist builders. This may contain
279
+ // sensitive config values; core/providers/env.js always reduces and scrubs it
280
+ // before a provider process receives anything.
281
+ function agentEnvSource() {
282
+ return {
283
+ ...process.env,
284
+ ...config,
285
+ PATH: FULL_PATH,
286
+ HOME: process.env.HOME || require("os").homedir(),
287
+ };
288
+ }
289
+
253
290
  module.exports = {
254
291
  config,
292
+ parseEnvText,
293
+ loadEnv,
255
294
  saveEnvKey,
256
295
  loadChannels,
257
296
  botSubprocessEnv,
297
+ agentEnvSource,
298
+ configuredProviderPath,
299
+ discoverProviderExecutable,
300
+ providerExecutableStatus,
301
+ resolveExecutablePath,
302
+ DEFAULT_PROVIDER,
303
+ UTILITY_PROVIDER,
304
+ PROVIDER_FALLBACKS,
305
+ UTILITY_PURPOSE_PROVIDERS,
306
+ ensureRuntimeDirectories,
258
307
  KAZEE_DEFAULT_URL,
259
308
  BOT_DIR,
260
309
  CONFIG_DIR,
261
310
  TOKEN, CHAT_IDS, CHAT_ID,
262
311
  WORKSPACE,
263
312
  CLAUDE_PATH,
264
- DEFAULT_CLAUDE_MODEL,
265
- CURSOR_PATH,
266
313
  CODEX_PATH,
267
- resolvedCursorPath,
268
314
  resolvedCodexPath,
269
315
  AUTO_COMPACT_TOKENS,
270
316
  MIN_COMPACT_INTERVAL_MS,
package/core/doctor.js CHANGED
@@ -2,40 +2,21 @@
2
2
  // before the user spends time on a task that's going to fail anyway.
3
3
 
4
4
  const fs = require("fs");
5
+ const os = require("os");
5
6
  const path = require("path");
6
- const { execSync } = require("child_process");
7
+ const { execFileSync } = require("child_process");
7
8
  const {
8
- CLAUDE_PATH, CURSOR_PATH, CODEX_PATH, resolvedCursorPath, resolvedCodexPath,
9
9
  WHISPER_CLI, WHISPER_MODEL, FFMPEG, WORKSPACE, CONFIG_DIR,
10
10
  botSubprocessEnv,
11
11
  } = require("./config");
12
- const { redactSensitive, stripTerminalControls } = require("./redact");
13
- const { isClaudeAuthErrorText, isCodexAuthErrorText, claudeSubprocessEnv } = require("./auth-flow");
14
-
15
- function shellQuote(value) {
16
- return `"${String(value).replace(/"/g, '\\"')}"`;
17
- }
18
-
19
- function runCommandForDoctor(command, args = [], opts = {}) {
20
- try {
21
- const out = execSync([command, ...args].map(shellQuote).join(" "), {
22
- cwd: opts.cwd || process.env.HOME || require("os").homedir(),
23
- env: opts.env || botSubprocessEnv(),
24
- encoding: "utf-8",
25
- timeout: opts.timeout || 10000,
26
- stdio: ["ignore", "pipe", "pipe"],
27
- });
28
- return { ok: true, output: out.trim(), code: 0 };
29
- } catch (e) {
30
- return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
31
- }
32
- }
12
+ const { redactSensitive } = require("./redact");
13
+ const { inspectProviderConfiguration } = require("./provider-status");
33
14
 
34
15
  function binaryCheck(binPath, name) {
35
16
  if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
36
17
  try {
37
18
  if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
38
- else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
19
+ else execFileSync(process.platform === "win32" ? "where" : "which", [String(binPath)], { stdio: "ignore", env: botSubprocessEnv() });
39
20
  return { ok: true, label: name, detail: binPath };
40
21
  } catch (e) {
41
22
  return { ok: false, label: name, detail: `not executable: ${binPath}` };
@@ -55,46 +36,119 @@ function checkWritableDir(dirPath, label) {
55
36
  }
56
37
  }
57
38
 
58
- function summarize(output, ok) {
59
- const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
60
- if (!clean) return ok ? "ok" : "no output";
61
- return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
39
+ function defaultCodexHookProbe({ binary }) {
40
+ const { prepareCodexHook, probeCodexHookCompatibility } = require("./providers/codex-hook");
41
+ const { codexSubprocessEnv } = require("./auth-flow");
42
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-hook-probe-"));
43
+ try {
44
+ return probeCodexHookCompatibility({
45
+ binary,
46
+ prepared: prepareCodexHook({ cwd: temp }),
47
+ cwd: temp,
48
+ markerFile: path.join(temp, "probe.marker"),
49
+ auditFile: path.join(temp, "probe-audit.jsonl"),
50
+ env: codexSubprocessEnv(),
51
+ // A real model turn, not a fixture: allow for slow first-token latency.
52
+ timeoutMs: 45000,
53
+ });
54
+ } finally {
55
+ fs.rmSync(temp, { recursive: true, force: true });
56
+ }
62
57
  }
63
58
 
64
- function runDoctorChecks() {
59
+ function codexHookEnforcementCheck(providerSummary, options) {
60
+ const label = "Codex tool-hook enforcement (live probe)";
61
+ const codex = providerSummary.providers.find((provider) => provider.id === "codex");
62
+ const ready = codex
63
+ && codex.availability.state === "available"
64
+ && codex.compatibility.state === "compatible"
65
+ && codex.auth.state === "authenticated";
66
+ if (!ready) {
67
+ return {
68
+ ok: true,
69
+ warn: true,
70
+ label,
71
+ detail: codex
72
+ ? `skipped — codex not ready (${codex.availability.state}/${codex.compatibility.state}/${codex.auth.state})`
73
+ : "skipped — codex is not registered",
74
+ action: "",
75
+ };
76
+ }
77
+ const runProbe = options.runCodexHookProbe || defaultCodexHookProbe;
78
+ try {
79
+ const probe = runProbe({ binary: codex.availability.executable });
80
+ return probe.ok
81
+ ? { ok: true, label, detail: "deny rule enforced end-to-end against the real CLI" }
82
+ : {
83
+ ok: false,
84
+ label,
85
+ detail: redactSensitive(probe.diagnostic || "probe failed"),
86
+ action: "Do not run Codex tasks until this passes — update the Codex CLI and re-run /doctor.",
87
+ };
88
+ } catch (e) {
89
+ return {
90
+ ok: false,
91
+ label,
92
+ detail: redactSensitive(e.message),
93
+ action: "Do not run Codex tasks until this passes — update the Codex CLI and re-run /doctor.",
94
+ };
95
+ }
96
+ }
97
+
98
+ function runDoctorChecks(options = {}) {
65
99
  const checks = [];
66
100
  const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
67
101
  checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
68
102
 
69
- const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
70
- if (claudeBin.ok) {
71
- const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
72
- const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
73
- checks.push({ ok: ver.ok, label: "Claude version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
74
- checks.push({ ok: auth.ok && !isClaudeAuthErrorText(auth.output), label: "Claude auth", detail: summarize(auth.output, auth.ok), action: auth.ok && !isClaudeAuthErrorText(auth.output) ? "" : "Run /auth_status then /setup_token or /login." });
75
- } else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
76
-
77
- if (CURSOR_PATH || resolvedCursorPath) {
78
- const cursorPath = resolvedCursorPath || CURSOR_PATH;
79
- const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
80
- if (cursorBin.ok) {
81
- const ver = runCommandForDoctor(cursorPath, ["--version"]);
82
- const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
83
- checks.push({ ok: ver.ok, label: "Cursor version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
84
- checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarize(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
85
- } else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
86
- } else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
103
+ const providerSummary = inspectProviderConfiguration({
104
+ registry: options.registry,
105
+ executableStatus: options.executableStatus,
106
+ probeAuth: options.probeAuth !== false,
107
+ });
108
+ for (const provider of providerSummary.providers) {
109
+ const available = provider.availability.state === "available";
110
+ const compatible = provider.compatibility.state === "compatible";
111
+ const authenticated = provider.auth.state === "authenticated";
112
+ checks.push({
113
+ ok: available,
114
+ warn: !available,
115
+ label: `${provider.label} availability`,
116
+ detail: available ? provider.availability.executable : provider.availability.state,
117
+ action: available ? "" : provider.recovery,
118
+ });
119
+ checks.push({
120
+ ok: available && compatible,
121
+ warn: !available || !compatible,
122
+ label: `${provider.label} compatibility`,
123
+ detail: provider.compatibility.version
124
+ ? `${provider.compatibility.state} (${provider.compatibility.version})`
125
+ : provider.compatibility.state,
126
+ action: available && !compatible ? provider.recovery : "",
127
+ });
128
+ checks.push({
129
+ ok: available && compatible && authenticated,
130
+ warn: !available || !compatible || !authenticated,
131
+ label: `${provider.label} auth`,
132
+ detail: provider.auth.state,
133
+ action: available && compatible && !authenticated ? provider.recovery : "",
134
+ });
135
+ }
136
+ // The live hook probe drives a real `codex exec` model turn (tokens,
137
+ // seconds), so it is strictly opt-in: only user-invoked /doctor passes it.
138
+ // Boot, health, and setup surfaces must never spawn a model prompt.
139
+ if (options.probeCodexHook === true) {
140
+ checks.push(codexHookEnforcementCheck(providerSummary, options));
141
+ }
87
142
 
88
- if (CODEX_PATH || resolvedCodexPath) {
89
- const codexPath = resolvedCodexPath || CODEX_PATH;
90
- const codexBin = binaryCheck(codexPath, "Codex CLI");
91
- if (codexBin.ok) {
92
- const ver = runCommandForDoctor(codexPath, ["--version"]);
93
- const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
94
- checks.push({ ok: ver.ok, label: "Codex version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
95
- checks.push({ ok: status.ok && !isCodexAuthErrorText(status.output), label: "Codex auth", detail: summarize(status.output, status.ok), action: status.ok && !isCodexAuthErrorText(status.output) ? "" : "Run /codex_login or /codex_setup_token." });
96
- } else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
97
- } else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
143
+ checks.push({
144
+ ok: !!providerSummary.defaultProvider.providerId,
145
+ warn: !providerSummary.defaultProvider.providerId,
146
+ label: "Default provider",
147
+ detail: providerSummary.defaultProvider.label
148
+ ? `${providerSummary.defaultProvider.label} (${providerSummary.defaultProvider.source})`
149
+ : "none",
150
+ action: providerSummary.defaultProvider.providerId ? "" : providerSummary.defaultProvider.reason,
151
+ });
98
152
 
99
153
  if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
100
154
  const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");