@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
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ function statusProvider(id, overrides = {}) {
12
+ const { CAPABILITY_KEYS } = require("./core/providers/contract");
13
+ const capabilities = Object.fromEntries(CAPABILITY_KEYS.map((key) => [key, {
14
+ support: "unsupported",
15
+ reason: "fixture capability",
16
+ ...(key === "effort" ? { values: [] } : {}),
17
+ }]));
18
+ return {
19
+ id,
20
+ label: id === "claude" ? "Claude Code" : "OpenAI Codex",
21
+ isAvailable: () => true,
22
+ executable: () => `/fixture/${id}`,
23
+ defaultModel: () => null,
24
+ tierModel: (tier) => `${id}-${tier}`,
25
+ modelChoices: () => [],
26
+ compatibilityStatus: () => overrides.compatibility || { state: "compatible", version: "1.0.0", reason: null },
27
+ capabilities,
28
+ buildMainInvocation: () => ({ binary: `/fixture/${id}`, args: [], env: {}, stdin: null, parserState: {} }),
29
+ buildUtilityInvocation: () => ({ binary: `/fixture/${id}`, args: [], env: {}, stdin: null, parserState: {} }),
30
+ createEventDecoder: () => ({ push: () => [], end: () => [] }),
31
+ normalizeEvent: () => [],
32
+ authStatus: () => overrides.auth || { state: "authenticated", reason: null },
33
+ authHelp: () => "fixture auth help",
34
+ };
35
+ }
36
+
37
+ function childProbe() {
38
+ const configDirApi = require("./core/config-dir");
39
+ const config = require("./core/config");
40
+
41
+ // Setup and status helpers must be importable without starting setup,
42
+ // writing state, probing a provider, or exiting the process.
43
+ require("./setup");
44
+ require("./health");
45
+ require("./core/doctor");
46
+
47
+ const result = {
48
+ configDir: config.CONFIG_DIR,
49
+ resolvedConfigDir: configDirApi.resolveConfigDir(process.env),
50
+ workspace: config.WORKSPACE,
51
+ claudeConfigured: config.configuredProviderPath("claude"),
52
+ codexConfigured: config.configuredProviderPath("codex"),
53
+ claudeAvailability: config.providerExecutableStatus("claude"),
54
+ codexAvailability: config.providerExecutableStatus("codex"),
55
+ };
56
+ process.stdout.write(`${JSON.stringify(result)}\n`);
57
+ }
58
+
59
+ if (process.argv[2] === "--probe") {
60
+ childProbe();
61
+ } else {
62
+ const { resolveConfigDir } = require("./core/config-dir");
63
+ assert.strictEqual(
64
+ resolveConfigDir({ HOME: "/fixture/home", OPEN_CLAUDIA_CONFIG_DIR: "/fixture/config" }),
65
+ path.resolve("/fixture/config"),
66
+ );
67
+ assert.strictEqual(
68
+ resolveConfigDir({ HOME: "/fixture/home" }),
69
+ path.resolve("/fixture/home/.open-claudia"),
70
+ );
71
+
72
+ const cases = [
73
+ { name: "claude-only", claude: "present", codex: null, expected: ["available", "unconfigured"] },
74
+ { name: "codex-only", claude: null, codex: "present", expected: ["unconfigured", "available"] },
75
+ { name: "both", claude: "present", codex: "present", expected: ["available", "available"] },
76
+ { name: "neither", claude: null, codex: null, expected: ["unconfigured", "unconfigured"] },
77
+ { name: "missing-claude", claude: "missing", codex: null, expected: ["missing", "unconfigured"] },
78
+ { name: "missing-codex", claude: null, codex: "missing", expected: ["unconfigured", "missing"] },
79
+ { name: "path-only-claude", claude: "path-only", codex: null, expected: ["available", "unconfigured"] },
80
+ { name: "path-only-codex", claude: null, codex: "path-only", expected: ["unconfigured", "available"] },
81
+ ];
82
+
83
+ for (const testCase of cases) {
84
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-bootstrap-${testCase.name}-`));
85
+ const home = path.join(root, "home");
86
+ const configDir = path.join(root, "config");
87
+ const workspace = path.join(root, "workspace");
88
+ const binDir = path.join(root, "bin");
89
+ fs.mkdirSync(home);
90
+ fs.mkdirSync(binDir);
91
+
92
+ const configuredPath = (providerName, disposition) => {
93
+ if (!disposition) return undefined;
94
+ if (disposition === "missing") return path.join(binDir, `${providerName}-missing`);
95
+ const target = path.join(binDir, providerName);
96
+ fs.symlinkSync(process.execPath, target);
97
+ return disposition === "path-only" ? undefined : target;
98
+ };
99
+
100
+ const env = {
101
+ HOME: home,
102
+ PATH: binDir,
103
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
104
+ WORKSPACE: workspace,
105
+ CHANNELS: "",
106
+ OPEN_CLAUDIA_TEST: "1",
107
+ CLAUDE_PATH: configuredPath("claude", testCase.claude),
108
+ CODEX_PATH: configuredPath("codex", testCase.codex),
109
+ };
110
+ for (const key of Object.keys(env)) {
111
+ if (env[key] === undefined) delete env[key];
112
+ }
113
+
114
+ try {
115
+ const child = spawnSync(process.execPath, [__filename, "--probe"], {
116
+ cwd: __dirname,
117
+ env,
118
+ encoding: "utf8",
119
+ timeout: 10000,
120
+ });
121
+ assert.ifError(child.error);
122
+ assert.strictEqual(child.status, 0, child.stderr || `${testCase.name} import probe failed`);
123
+ const output = JSON.parse(child.stdout.trim());
124
+ assert.strictEqual(output.configDir, path.resolve(configDir));
125
+ assert.strictEqual(output.resolvedConfigDir, path.resolve(configDir));
126
+ assert.strictEqual(output.workspace, workspace);
127
+ assert.deepStrictEqual(
128
+ [output.claudeAvailability.state, output.codexAvailability.state],
129
+ testCase.expected,
130
+ );
131
+ assert.ok(!fs.existsSync(configDir), `${testCase.name} imports must not create the config directory`);
132
+ assert.ok(!fs.existsSync(workspace), `${testCase.name} imports must not create the workspace`);
133
+ } finally {
134
+ fs.rmSync(root, { recursive: true, force: true });
135
+ }
136
+ }
137
+
138
+ // Compatibility and auth are provider-owned typed states. They are kept
139
+ // separate so setup/status can explain an old CLI versus a logged-out CLI.
140
+ const { ProviderRegistry } = require("./core/providers");
141
+ const oldCli = new ProviderRegistry();
142
+ oldCli.registerProvider(statusProvider("claude", {
143
+ compatibility: { state: "incompatible", version: "0.0.1", reason: "too old" },
144
+ }));
145
+ assert.strictEqual(oldCli.providerStatus("claude").compatibility.state, "incompatible");
146
+
147
+ const loggedOut = new ProviderRegistry();
148
+ loggedOut.registerProvider(statusProvider("codex", {
149
+ auth: { state: "unauthenticated", reason: "login required" },
150
+ }));
151
+ assert.strictEqual(loggedOut.providerStatus("codex").auth.state, "unauthenticated");
152
+
153
+ const cliSource = fs.readFileSync(path.join(__dirname, "bin", "cli.js"), "utf8");
154
+ assert.match(cliSource, /case "setup":[\s\S]*setup\.js"\)\)\.main\(\)/, "CLI setup command must call the guarded setup entrypoint");
155
+ assert.match(cliSource, /Legacy:[\s\S]*setup\.js"\)\)\.main\(\)/, "legacy auth command must call the guarded setup entrypoint");
156
+
157
+ console.log("provider bootstrap OK");
158
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-capabilities-"));
11
+ const workspace = path.join(configDir, "workspace");
12
+ fs.mkdirSync(path.join(workspace, "alpha"), { recursive: true });
13
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
14
+ process.env.WORKSPACE = workspace;
15
+ process.env.TELEGRAM_CHAT_ID = "fixture-chat";
16
+ process.env.CHANNELS = "";
17
+ process.env.OPEN_CLAUDIA_TEST = "1";
18
+ process.env.CLAUDE_PATH = process.execPath;
19
+ process.env.CODEX_PATH = process.execPath;
20
+
21
+ const stateApi = require("./core/state");
22
+ const handlers = require("./core/handlers");
23
+ const commands = require("./core/commands");
24
+ const providers = require("./core/providers");
25
+ const { handleAction } = require("./core/actions");
26
+ const { runInChat } = require("./core/context");
27
+
28
+ const state = stateApi.currentState();
29
+ state.currentSession = { name: "alpha", dir: path.join(workspace, "alpha") };
30
+ state.settings.backend = "claude";
31
+
32
+ function callbacks(rows) {
33
+ return rows.flat().map((button) => button.callback_data).filter(Boolean);
34
+ }
35
+
36
+ function pickerProbe() {
37
+ assert.strictEqual(typeof handlers.buildBackendPicker, "function");
38
+ assert.strictEqual(typeof handlers.buildModelPicker, "function");
39
+ const backend = handlers.buildBackendPicker({ state, registry: providers });
40
+ const expectedProviders = providers.listProviders().map((provider) => provider.id);
41
+ assert.deepStrictEqual(
42
+ callbacks(backend.rows).map((value) => value.slice("be:".length)),
43
+ expectedProviders,
44
+ "backend picker comes from registry order",
45
+ );
46
+
47
+ const model = handlers.buildModelPicker({ state, registry: providers });
48
+ const modelCallbacks = callbacks(model.rows).filter((value) => value.startsWith("mb:"));
49
+ for (const provider of providers.listProviders()) {
50
+ const choices = provider.modelChoices();
51
+ for (const choice of choices) {
52
+ assert.ok(modelCallbacks.includes(`mb:${provider.id}:${choice}`), `${provider.id} model choice ${choice}`);
53
+ }
54
+ assert.ok(modelCallbacks.includes(`mb:${provider.id}:default`), `${provider.id} default model control`);
55
+ }
56
+ assert.ok(!fs.readFileSync(path.join(__dirname, "core", "handlers.js"), "utf8").includes("DEFAULT_CLAUDE_MODEL"));
57
+ }
58
+
59
+ function mutationProbe() {
60
+ assert.strictEqual(typeof handlers.applyProviderSetting, "function");
61
+ const claudeSettings = stateApi.getProviderSettings(state, "claude");
62
+ const codexSettings = stateApi.getProviderSettings(state, "codex");
63
+ claudeSettings.effort = "low";
64
+ claudeSettings.budget = 2;
65
+ codexSettings.effort = null;
66
+ codexSettings.budget = null;
67
+ codexSettings.worktree = false;
68
+
69
+ state.settings.backend = "codex";
70
+ let result = handlers.applyProviderSetting(state, "codex", "effort", "xhigh", { registry: providers });
71
+ assert.strictEqual(result.ok, true);
72
+ assert.strictEqual(codexSettings.effort, "xhigh");
73
+ assert.strictEqual(claudeSettings.effort, "low", "provider setting stays isolated");
74
+
75
+ const beforeBudget = structuredClone(state.providerSettings);
76
+ result = handlers.applyProviderSetting(state, "codex", "budget", 5, { registry: providers });
77
+ assert.strictEqual(result.ok, false);
78
+ assert.strictEqual(result.code, "UNSUPPORTED_PROVIDER_CAPABILITY");
79
+ assert.match(result.message, /no per-run monetary budget/i);
80
+ assert.deepStrictEqual(state.providerSettings, beforeBudget, "unsupported budget cannot mutate state");
81
+
82
+ const beforeWorktree = structuredClone(state.providerSettings);
83
+ result = handlers.applyProviderSetting(state, "codex", "worktree", true, { registry: providers });
84
+ assert.strictEqual(result.ok, false);
85
+ assert.match(result.message, /worktree/i);
86
+ assert.deepStrictEqual(state.providerSettings, beforeWorktree, "unsupported worktree cannot mutate state");
87
+
88
+ const beforeInvalidEffort = structuredClone(state.providerSettings);
89
+ result = handlers.applyProviderSetting(state, "codex", "effort", "maximum-ish", { registry: providers });
90
+ assert.strictEqual(result.ok, false);
91
+ assert.deepStrictEqual(state.providerSettings, beforeInvalidEffort, "invalid effort cannot mutate state");
92
+
93
+ state.settings.backend = "claude";
94
+ result = handlers.applyProviderSetting(state, "claude", "budget", 4, { registry: providers });
95
+ assert.strictEqual(result.ok, true);
96
+ assert.strictEqual(claudeSettings.budget, 4);
97
+
98
+ const stale = structuredClone(state.providerSettings);
99
+ result = handlers.applyProviderSetting(state, "codex", "effort", "high", { registry: providers, requireActive: true });
100
+ assert.strictEqual(result.code, "STALE_PROVIDER_CONTROL");
101
+ assert.deepStrictEqual(state.providerSettings, stale, "stale provider control cannot mutate inactive settings");
102
+ }
103
+
104
+ async function inChat(fn, sent) {
105
+ return runInChat({
106
+ adapter: {
107
+ id: "fixture",
108
+ type: "fixture",
109
+ send: async (_channelId, text, options) => { sent.push({ text, options }); return true; },
110
+ },
111
+ channelId: "fixture-chat",
112
+ canonicalUserId: "telegram:fixture-chat",
113
+ userId: "fixture-chat",
114
+ }, fn);
115
+ }
116
+
117
+ async function commandProbe() {
118
+ const names = new Set(commands.list().map((command) => command.name));
119
+ assert.ok(names.has("ask"), "/ask is registered");
120
+ const sent = [];
121
+ const dispatch = (text) => inChat(() => commands.dispatch(text, {
122
+ adapter: { id: "fixture", type: "fixture" },
123
+ channelId: "fixture-chat",
124
+ canonicalUserId: "telegram:fixture-chat",
125
+ userId: "fixture-chat",
126
+ }), sent);
127
+
128
+ state.settings.backend = "codex";
129
+ stateApi.getProviderSettings(state, "codex").effort = null;
130
+ await dispatch("/effort high");
131
+ assert.strictEqual(stateApi.getProviderSettings(state, "codex").effort, "high", "Codex native effort is accepted");
132
+
133
+ const beforeBudget = structuredClone(state.providerSettings);
134
+ await dispatch("/budget 5");
135
+ assert.deepStrictEqual(state.providerSettings, beforeBudget);
136
+ assert.match(sent.at(-1).text, /no per-run monetary budget/i);
137
+
138
+ const beforeWorktree = structuredClone(state.providerSettings);
139
+ await dispatch("/worktree");
140
+ assert.deepStrictEqual(state.providerSettings, beforeWorktree);
141
+ assert.match(sent.at(-1).text, /worktree/i);
142
+
143
+ await dispatch("/plan");
144
+ assert.strictEqual(stateApi.getProviderSettings(state, "codex").permissionMode, "plan");
145
+ await dispatch("/ask");
146
+ assert.strictEqual(stateApi.getProviderSettings(state, "codex").permissionMode, "ask");
147
+
148
+ await dispatch("/status");
149
+ assert.match(sent.at(-1).text, /Provider: OpenAI Codex/);
150
+ assert.match(sent.at(-1).text, /Capabilities:/);
151
+ assert.match(sent.at(-1).text, /budget unsupported/i);
152
+
153
+ await dispatch("/backend");
154
+ assert.deepStrictEqual(
155
+ callbacks(sent.at(-1).options.keyboard.inline_keyboard).map((value) => value.slice(3)),
156
+ providers.listProviders().map((provider) => provider.id),
157
+ );
158
+
159
+ await dispatch("/model");
160
+ const modelControls = callbacks(sent.at(-1).options.keyboard.inline_keyboard);
161
+ assert.ok(modelControls.includes("mb:codex:gpt-5.4"));
162
+ assert.ok(modelControls.includes("mb:codex:gpt-5.4-mini"));
163
+ assert.ok(!modelControls.includes("mb:codex:o4-mini"));
164
+ assert.ok(modelControls.includes("mb:claude:claude-sonnet-4-6"));
165
+ }
166
+
167
+ async function callbackProbe() {
168
+ state.settings.backend = "codex";
169
+ stateApi.getProviderSettings(state, "codex").budget = null;
170
+ const before = structuredClone(state.providerSettings);
171
+ const sent = [];
172
+ await inChat(() => handleAction({
173
+ adapter: { id: "fixture", type: "fixture" },
174
+ channelId: "fixture-chat",
175
+ canonicalUserId: "telegram:fixture-chat",
176
+ userId: "fixture-chat",
177
+ action: { payload: "b:codex:5" },
178
+ }), sent);
179
+ assert.deepStrictEqual(state.providerSettings, before, "unsupported callback cannot mutate state");
180
+ assert.match(sent.at(-1).text, /no per-run monetary budget/i);
181
+
182
+ state.settings.backend = "claude";
183
+ const stale = structuredClone(state.providerSettings);
184
+ await inChat(() => handleAction({
185
+ adapter: { id: "fixture", type: "fixture" },
186
+ channelId: "fixture-chat",
187
+ canonicalUserId: "telegram:fixture-chat",
188
+ userId: "fixture-chat",
189
+ action: { payload: "e:codex:high" },
190
+ }), sent);
191
+ assert.deepStrictEqual(state.providerSettings, stale, "stale callback cannot mutate inactive provider");
192
+ assert.match(sent.at(-1).text, /stale/i);
193
+ }
194
+
195
+ async function adapterReadOnlyProbe() {
196
+ const { createClaudeProvider } = require("./core/providers/claude");
197
+ const { createCodexProvider } = require("./core/providers/codex");
198
+ const common = {
199
+ resolveExecutable: () => process.execPath,
200
+ buildEnv: () => ({}),
201
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
202
+ hookTransport: false,
203
+ };
204
+ const context = {
205
+ coreInstructions: "policy",
206
+ dynamicContext: "context",
207
+ userPrompt: "question",
208
+ providerSettings: { permissionMode: "ask" },
209
+ fresh: true,
210
+ };
211
+ const claude = createClaudeProvider(common).buildMainInvocation(context);
212
+ assert.deepStrictEqual(claude.args.slice(claude.args.indexOf("--permission-mode"), claude.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
213
+ const codex = createCodexProvider({ ...common, defaultModel: "gpt-fixture" }).buildMainInvocation(context);
214
+ assert.deepStrictEqual(codex.args.slice(codex.args.indexOf("--sandbox"), codex.args.indexOf("--sandbox") + 2), ["--sandbox", "read-only"]);
215
+ assert.ok(!codex.args.includes("--dangerously-bypass-approvals-and-sandbox"));
216
+ }
217
+
218
+ async function main() {
219
+ pickerProbe();
220
+ mutationProbe();
221
+ await commandProbe();
222
+ await callbackProbe();
223
+ await adapterReadOnlyProbe();
224
+ console.log("provider capabilities OK");
225
+ }
226
+
227
+ main().catch((error) => {
228
+ console.error(error.stack || error.message);
229
+ process.exitCode = 1;
230
+ }).finally(() => {
231
+ fs.rmSync(configDir, { recursive: true, force: true });
232
+ });
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { validateInvocation, validateProvider } = require("./core/providers/contract");
9
+ const { createClaudeProvider } = require("./core/providers/claude");
10
+ const { getProvider } = require("./core/providers");
11
+
12
+ function deepFreeze(value) {
13
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
14
+ for (const child of Object.values(value)) deepFreeze(child);
15
+ return Object.freeze(value);
16
+ }
17
+
18
+ const safeEnv = {
19
+ PATH: "/fixture/bin",
20
+ HOME: "/fixture/home",
21
+ CLAUDE_CODE_OAUTH_TOKEN: "fixture-present-not-a-real-token",
22
+ };
23
+ let authState = { state: "authenticated", reason: null, method: "fixture" };
24
+ const provider = createClaudeProvider({
25
+ hookTransport: false,
26
+ resolveExecutable: () => "/fixture/claude",
27
+ defaultModel: "claude-default-fixture",
28
+ buildEnv: () => ({ ...safeEnv }),
29
+ getAuthStatus: () => authState,
30
+ runCommand: (_binary, args) => {
31
+ if (args[0] === "--version") return { ok: true, output: "2.1.150 (Claude Code)", code: 0 };
32
+ if (args[0] === "--help") return {
33
+ ok: true,
34
+ output: "--append-system-prompt --output-format --resume --permission-mode --settings",
35
+ code: 0,
36
+ };
37
+ return { ok: false, output: "unexpected fixture command", code: 1 };
38
+ },
39
+ });
40
+
41
+ assert.strictEqual(validateProvider(provider), provider);
42
+ assert.strictEqual(provider.id, "claude");
43
+ assert.strictEqual(getProvider("claude").id, "claude", "built-in registry exposes the adapter without probing it at import");
44
+ assert.strictEqual(provider.label, "Claude Code");
45
+ assert.strictEqual(provider.isAvailable(), true);
46
+ assert.strictEqual(provider.executable(), "/fixture/claude");
47
+ assert.strictEqual(provider.defaultModel(), "claude-default-fixture");
48
+ assert.ok(provider.modelChoices().includes("claude-fable-5"));
49
+ assert.ok(provider.modelChoices().includes("claude-sonnet-4-6"));
50
+ assert.deepStrictEqual(provider.compatibilityStatus(), {
51
+ state: "compatible",
52
+ version: "2.1.150",
53
+ reason: null,
54
+ });
55
+ assert.strictEqual(provider.capabilities.resume.support, "native");
56
+ assert.strictEqual(provider.capabilities.readOnlyMode.support, "native");
57
+ assert.deepStrictEqual(provider.capabilities.effort.values, ["low", "medium", "high", "xhigh", "max"]);
58
+ assert.strictEqual(provider.capabilities.budget.support, "native");
59
+ assert.strictEqual(provider.capabilities.worktree.support, "native");
60
+ assert.strictEqual(provider.capabilities.maxTurns.support, "native");
61
+ assert.strictEqual(provider.capabilities.partialStreaming.support, "native");
62
+ assert.strictEqual(provider.capabilities.persistentUtilityStream.support, "native");
63
+ assert.strictEqual(provider.capabilities.preToolHook.support, "native");
64
+ assert.strictEqual(provider.capabilities.imageFlag.support, "unsupported");
65
+
66
+ const richContext = deepFreeze({
67
+ coreInstructions: "STABLE CORE",
68
+ dynamicContext: "DYNAMIC CONTEXT",
69
+ userPrompt: "USER SENTINEL",
70
+ fresh: true,
71
+ sessionId: "ignored-because-fresh",
72
+ providerSettings: {
73
+ model: "claude-sonnet-4-6",
74
+ effort: "high",
75
+ budget: 2.5,
76
+ permissionMode: "plan",
77
+ worktree: true,
78
+ },
79
+ maxTurns: 4,
80
+ transcriptDirectory: "/fixture/transcripts",
81
+ voiceStream: true,
82
+ toolHookSettings: "/fixture/deny-gate.json",
83
+ });
84
+ const before = JSON.stringify(richContext);
85
+ const richInvocation = validateInvocation(provider.buildMainInvocation(richContext));
86
+ assert.strictEqual(JSON.stringify(richContext), before, "frozen run context must not be mutated");
87
+ assert.strictEqual(richInvocation.binary, "/fixture/claude");
88
+ assert.deepStrictEqual(richInvocation.args, [
89
+ "-p",
90
+ "--verbose",
91
+ "--output-format", "stream-json",
92
+ "--append-system-prompt", "STABLE CORE",
93
+ "--settings", "/fixture/deny-gate.json",
94
+ "--add-dir", "/fixture/transcripts",
95
+ "--model", "claude-sonnet-4-6",
96
+ "--max-turns", "4",
97
+ "--effort", "high",
98
+ "--max-budget-usd", "2.5",
99
+ "--permission-mode", "plan",
100
+ "--worktree",
101
+ "--include-partial-messages",
102
+ "DYNAMIC CONTEXT\n\nUSER SENTINEL",
103
+ ]);
104
+ assert.deepStrictEqual(richInvocation.env, safeEnv);
105
+ assert.strictEqual(richInvocation.stdin, null);
106
+ assert.ok(richInvocation.parserState.seenUsage instanceof Set);
107
+
108
+ function simpleContext(overrides = {}) {
109
+ return deepFreeze({
110
+ coreInstructions: "CORE",
111
+ dynamicContext: "",
112
+ userPrompt: "PROMPT",
113
+ providerSettings: {},
114
+ ...overrides,
115
+ });
116
+ }
117
+
118
+ const fresh = provider.buildMainInvocation(simpleContext({ fresh: true, sessionId: "old" }));
119
+ assert.ok(!fresh.args.includes("--resume"));
120
+ assert.ok(!fresh.args.includes("--continue"));
121
+ assert.ok(fresh.args.includes("--dangerously-skip-permissions"), "direct mode preserves current behavior");
122
+
123
+ const resumed = provider.buildMainInvocation(simpleContext({ resumeSessionId: "resume-1" }));
124
+ assert.deepStrictEqual(resumed.args.slice(6, 10), ["--resume", "resume-1", "--model", "claude-default-fixture"]);
125
+
126
+ const implicitResume = provider.buildMainInvocation(simpleContext({ sessionId: "active-1" }));
127
+ assert.ok(implicitResume.args.indexOf("--resume") >= 0);
128
+ assert.strictEqual(implicitResume.args[implicitResume.args.indexOf("--resume") + 1], "active-1");
129
+
130
+ const continued = provider.buildMainInvocation(simpleContext({ continueSession: true, sessionId: "active-1" }));
131
+ assert.ok(continued.args.includes("--continue"));
132
+ assert.ok(!continued.args.includes("--resume"));
133
+
134
+ const readOnlyAsk = provider.buildMainInvocation(simpleContext({ providerSettings: { permissionMode: "ask" } }));
135
+ assert.deepStrictEqual(readOnlyAsk.args.slice(readOnlyAsk.args.indexOf("--permission-mode"), -1), ["--permission-mode", "plan"]);
136
+
137
+ assert.throws(
138
+ () => provider.buildMainInvocation(simpleContext({ resumeSessionId: "one", continueSession: true })),
139
+ (error) => error && error.code === "INVALID_PROVIDER_CONTEXT",
140
+ );
141
+ assert.throws(
142
+ () => provider.buildMainInvocation(simpleContext({ providerSettings: { effort: "extreme" } })),
143
+ (error) => error && error.code === "UNSUPPORTED_PROVIDER_SETTING",
144
+ );
145
+ assert.throws(
146
+ () => provider.buildMainInvocation(simpleContext({ providerSettings: { budget: -1 } })),
147
+ (error) => error && error.code === "INVALID_PROVIDER_CONTEXT",
148
+ );
149
+ assert.throws(
150
+ () => provider.buildMainInvocation(deepFreeze({ dynamicContext: "only", userPrompt: "raw" })),
151
+ (error) => error && error.code === "MISSING_CORE_INSTRUCTIONS",
152
+ );
153
+
154
+ const utility = validateInvocation(provider.buildUtilityInvocation(deepFreeze({
155
+ coreInstructions: "UTILITY CORE",
156
+ userPrompt: "UTILITY PROMPT",
157
+ readOnly: true,
158
+ allowedTools: ["Read", "Glob"],
159
+ disallowedTools: ["Write"],
160
+ outputSchema: { type: "object", required: ["ok"] },
161
+ tier: "low",
162
+ maxTurns: 2,
163
+ toolHookSettings: "/fixture/utility-hook.json",
164
+ })));
165
+ assert.ok(utility.args.includes("--no-session-persistence"));
166
+ assert.deepStrictEqual(utility.args.slice(utility.args.indexOf("--allowedTools"), utility.args.indexOf("--allowedTools") + 2), ["--allowedTools", "Read,Glob"]);
167
+ assert.deepStrictEqual(utility.args.slice(utility.args.indexOf("--permission-mode"), utility.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
168
+ assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "claude-haiku-4-5-20251001");
169
+ assert.strictEqual(utility.args[utility.args.indexOf("--json-schema") + 1], JSON.stringify({ type: "object", required: ["ok"] }));
170
+ assert.strictEqual(utility.stdin, "UTILITY PROMPT", "utility prompt uses protected stdin transport");
171
+ assert.ok(!utility.args.includes("UTILITY PROMPT"), "utility prompt is absent from argv");
172
+
173
+ const decoder = provider.createEventDecoder();
174
+ const raw = decoder.push(Buffer.from('{"type":"system","subtype":"init","session_id":"bound-session"}\n'));
175
+ assert.deepStrictEqual(provider.normalizeEvent(raw[0], richInvocation.parserState), [{ type: "session", sessionId: "bound-session" }]);
176
+
177
+ assert.strictEqual(provider.authStatus().state, "authenticated");
178
+ assert.strictEqual(provider.preflightAuth(), null);
179
+ authState = { state: "unauthenticated", reason: "login required" };
180
+ const authFailure = provider.preflightAuth();
181
+ assert.strictEqual(authFailure.code, "PROVIDER_UNAUTHENTICATED");
182
+ assert.match(authFailure.message, /Claude auth needs attention/);
183
+ assert.match(provider.authHelp(), /setup_token/);
184
+
185
+ const missing = createClaudeProvider({ hookTransport: false, resolveExecutable: () => null, getAuthStatus: () => ({ state: "not_checked", reason: "missing" }) });
186
+ assert.strictEqual(missing.isAvailable(), false);
187
+ assert.throws(
188
+ () => missing.buildMainInvocation(simpleContext()),
189
+ (error) => error && error.code === "NO_AVAILABLE_PROVIDER",
190
+ );
191
+
192
+ const incompatible = createClaudeProvider({
193
+ hookTransport: false,
194
+ resolveExecutable: () => "/fixture/old-claude",
195
+ buildEnv: () => ({ PATH: "/fixture" }),
196
+ getAuthStatus: () => ({ state: "unknown", reason: "not checked" }),
197
+ runCommand: (_binary, args) => args[0] === "--version"
198
+ ? { ok: true, output: "0.1.0", code: 0 }
199
+ : { ok: true, output: "--output-format", code: 0 },
200
+ });
201
+ assert.strictEqual(incompatible.compatibilityStatus().state, "incompatible");
202
+
203
+ const source = fs.readFileSync(path.join(__dirname, "core", "providers", "claude.js"), "utf8");
204
+ assert.ok(!source.includes('require("child_process")'), "provider adapter must return invocation data instead of spawning");
205
+
206
+ console.log("Claude provider OK");