@inetafrica/open-claudia 2.14.8 → 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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -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 +105 -7
  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 +127 -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 +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
@@ -0,0 +1,228 @@
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 { createCodexProvider } = require("./core/providers/codex");
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 = { PATH: "/fixture/bin", HOME: "/fixture/home", OPENAI_API_KEY: "fixture-not-a-real-key" };
19
+ let authState = { state: "authenticated", reason: null, method: "fixture" };
20
+ const provider = createCodexProvider({
21
+ hookTransport: false,
22
+ resolveExecutable: () => "/fixture/codex",
23
+ defaultModel: "gpt-5-codex",
24
+ buildEnv: () => ({ ...safeEnv }),
25
+ getAuthStatus: () => authState,
26
+ runCommand: (_binary, args) => {
27
+ if (args[0] === "--version") return { ok: true, output: "codex-cli 0.144.0", code: 0 };
28
+ if (args.join(" ") === "exec --help") return {
29
+ ok: true,
30
+ output: "--config --json --sandbox --image --model --skip-git-repo-check",
31
+ code: 0,
32
+ };
33
+ if (args.join(" ") === "exec resume --help") return {
34
+ ok: true,
35
+ output: "--config --json --image --model --skip-git-repo-check",
36
+ code: 0,
37
+ };
38
+ return { ok: false, output: "unexpected fixture command", code: 1 };
39
+ },
40
+ });
41
+
42
+ assert.strictEqual(validateProvider(provider), provider);
43
+ assert.strictEqual(provider.id, "codex");
44
+ assert.strictEqual(getProvider("codex").id, "codex", "built-in registry exposes Codex lazily");
45
+ assert.strictEqual(provider.label, "OpenAI Codex");
46
+ assert.strictEqual(provider.isAvailable(), true);
47
+ assert.strictEqual(provider.executable(), "/fixture/codex");
48
+ assert.strictEqual(provider.defaultModel(), "gpt-5-codex");
49
+ assert.ok(provider.modelChoices().includes("gpt-5.4-mini"));
50
+ assert.ok(!provider.modelChoices().includes("o4-mini"));
51
+ assert.deepStrictEqual(provider.compatibilityStatus(), {
52
+ state: "compatible",
53
+ version: "0.144.0",
54
+ reason: null,
55
+ });
56
+
57
+ assert.strictEqual(provider.capabilities.resume.support, "native");
58
+ assert.strictEqual(provider.capabilities.readOnlyMode.support, "native");
59
+ assert.strictEqual(provider.capabilities.effort.support, "native");
60
+ assert.strictEqual(provider.capabilities.imageFlag.support, "native");
61
+ for (const key of ["budget", "worktree", "maxTurns"]) {
62
+ assert.strictEqual(provider.capabilities[key].support, "unsupported");
63
+ assert.ok(provider.capabilities[key].reason);
64
+ }
65
+ assert.strictEqual(provider.capabilities.partialStreaming.support, "unsupported");
66
+ assert.strictEqual(provider.capabilities.persistentUtilityStream.support, "unsupported");
67
+ assert.strictEqual(provider.capabilities.preToolHook.support, "native");
68
+
69
+ const freshContext = deepFreeze({
70
+ coreInstructions: "STABLE CORE",
71
+ dynamicContext: "DYNAMIC CONTEXT",
72
+ userPrompt: "USER SENTINEL",
73
+ fresh: true,
74
+ sessionId: "ignored-thread",
75
+ providerSettings: {
76
+ model: "gpt-5-codex",
77
+ effort: "high",
78
+ permissionMode: "plan",
79
+ budget: null,
80
+ worktree: false,
81
+ },
82
+ imagePaths: ["/fixture/one.png", "/fixture/two.png"],
83
+ });
84
+ const before = JSON.stringify(freshContext);
85
+ const fresh = validateInvocation(provider.buildMainInvocation(freshContext));
86
+ assert.strictEqual(JSON.stringify(freshContext), before, "frozen run context must not be mutated");
87
+ assert.deepStrictEqual(fresh.args, [
88
+ "exec",
89
+ "-c", `developer_instructions=${JSON.stringify("STABLE CORE")}`,
90
+ "-c", `model_reasoning_effort=${JSON.stringify("high")}`,
91
+ "--image", "/fixture/one.png", "/fixture/two.png",
92
+ "--json",
93
+ "--skip-git-repo-check",
94
+ "--sandbox", "read-only",
95
+ "--model", "gpt-5-codex",
96
+ "DYNAMIC CONTEXT\n\nUSER SENTINEL",
97
+ ]);
98
+ assert.deepStrictEqual(fresh.env, safeEnv);
99
+ assert.strictEqual(fresh.stdin, null);
100
+ assert.ok(fresh.parserState.seenUsage instanceof Set);
101
+ assert.ok(!Object.prototype.hasOwnProperty.call(fresh.env, "CLAUDE_CODE_OAUTH_TOKEN"));
102
+
103
+ function simpleContext(overrides = {}) {
104
+ return deepFreeze({
105
+ coreInstructions: "CORE",
106
+ dynamicContext: "",
107
+ userPrompt: "PROMPT",
108
+ providerSettings: { budget: null, worktree: false },
109
+ ...overrides,
110
+ });
111
+ }
112
+
113
+ const resumed = provider.buildMainInvocation(simpleContext({
114
+ resumeSessionId: "thread-1",
115
+ providerSettings: { effort: "medium", permissionMode: "plan", budget: null, worktree: false },
116
+ imagePaths: ["/fixture/resume.png"],
117
+ }));
118
+ assert.deepStrictEqual(resumed.args, [
119
+ "exec", "resume",
120
+ "-c", `developer_instructions=${JSON.stringify("CORE")}`,
121
+ "-c", `model_reasoning_effort=${JSON.stringify("medium")}`,
122
+ "-c", `sandbox_mode=${JSON.stringify("read-only")}`,
123
+ "--image", "/fixture/resume.png",
124
+ "--json",
125
+ "--skip-git-repo-check",
126
+ "--model", "gpt-5-codex",
127
+ "thread-1",
128
+ "PROMPT",
129
+ ]);
130
+ assert.ok(!resumed.args.includes("--sandbox"));
131
+ assert.ok(resumed.args.indexOf("thread-1") > resumed.args.lastIndexOf("--model"));
132
+
133
+ const directResume = provider.buildMainInvocation(simpleContext({ sessionId: "thread-direct" }));
134
+ assert.ok(directResume.args.includes("--dangerously-bypass-approvals-and-sandbox"));
135
+ assert.ok(directResume.args.indexOf("--dangerously-bypass-approvals-and-sandbox") < directResume.args.indexOf("thread-direct"));
136
+
137
+ const continued = provider.buildMainInvocation(simpleContext({ continueSession: true, sessionId: "thread-continue" }));
138
+ assert.strictEqual(continued.args[1], "resume");
139
+ assert.ok(continued.args.includes("thread-continue"));
140
+
141
+ const freshIgnoringSession = provider.buildMainInvocation(simpleContext({ fresh: true, sessionId: "old-thread" }));
142
+ assert.strictEqual(freshIgnoringSession.args[1], "-c");
143
+ assert.ok(!freshIgnoringSession.args.includes("old-thread"));
144
+
145
+ assert.throws(
146
+ () => provider.buildMainInvocation(simpleContext({ continueSession: true })),
147
+ (error) => error && error.code === "INVALID_PROVIDER_CONTEXT",
148
+ );
149
+ for (const providerSettings of [
150
+ { budget: 1, worktree: false },
151
+ { budget: null, worktree: true },
152
+ ]) {
153
+ assert.throws(
154
+ () => provider.buildMainInvocation(simpleContext({ providerSettings })),
155
+ (error) => error && error.code === "UNSUPPORTED_PROVIDER_SETTING",
156
+ );
157
+ }
158
+ assert.throws(
159
+ () => provider.buildMainInvocation(simpleContext({ maxTurns: 2 })),
160
+ (error) => error && error.code === "UNSUPPORTED_PROVIDER_SETTING",
161
+ );
162
+ assert.throws(
163
+ () => provider.buildMainInvocation(simpleContext({ voiceStream: true })),
164
+ (error) => error && error.code === "UNSUPPORTED_PROVIDER_CAPABILITY",
165
+ );
166
+ assert.throws(
167
+ () => provider.buildMainInvocation(simpleContext({ providerSettings: { effort: "max", budget: null, worktree: false } })),
168
+ (error) => error && error.code === "UNSUPPORTED_PROVIDER_SETTING",
169
+ );
170
+ assert.throws(
171
+ () => provider.buildMainInvocation(deepFreeze({ dynamicContext: "only", userPrompt: "raw" })),
172
+ (error) => error && error.code === "MISSING_CORE_INSTRUCTIONS",
173
+ );
174
+
175
+ const utility = validateInvocation(provider.buildUtilityInvocation(deepFreeze({
176
+ coreInstructions: "UTILITY CORE",
177
+ userPrompt: "UTILITY PROMPT",
178
+ readOnly: true,
179
+ tier: "low",
180
+ outputSchemaPath: "/fixture/schema.json",
181
+ })));
182
+ assert.ok(utility.args.includes("--ephemeral"));
183
+ assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "gpt-5.4-mini");
184
+ assert.strictEqual(utility.args[utility.args.indexOf("--output-schema") + 1], "/fixture/schema.json");
185
+ assert.deepStrictEqual(
186
+ utility.args.slice(utility.args.indexOf("--sandbox"), utility.args.indexOf("--sandbox") + 2),
187
+ ["--sandbox", "read-only"],
188
+ );
189
+ assert.strictEqual(utility.args.at(-1), "-", "Codex utility invocation reads its protected prompt from stdin");
190
+ assert.strictEqual(utility.stdin, "UTILITY PROMPT");
191
+ assert.ok(!utility.args.includes("UTILITY PROMPT"));
192
+
193
+ const decoder = provider.createEventDecoder();
194
+ const raw = decoder.push(Buffer.from('{"type":"thread.started","thread_id":"bound-thread"}\n'));
195
+ assert.deepStrictEqual(provider.normalizeEvent(raw[0], fresh.parserState), [{ type: "session", sessionId: "bound-thread" }]);
196
+
197
+ assert.strictEqual(provider.authStatus().state, "authenticated");
198
+ assert.strictEqual(provider.preflightAuth(), null);
199
+ authState = { state: "unauthenticated", reason: "login required" };
200
+ const authFailure = provider.preflightAuth();
201
+ assert.strictEqual(authFailure.code, "PROVIDER_UNAUTHENTICATED");
202
+ assert.match(authFailure.message, /Codex authentication/i);
203
+ assert.match(provider.authHelp(), /codex_login/);
204
+
205
+ const missing = createCodexProvider({ hookTransport: false, resolveExecutable: () => null, getAuthStatus: () => ({ state: "not_checked", reason: "missing" }) });
206
+ assert.strictEqual(missing.isAvailable(), false);
207
+ assert.throws(
208
+ () => missing.buildMainInvocation(simpleContext()),
209
+ (error) => error && error.code === "NO_AVAILABLE_PROVIDER",
210
+ );
211
+
212
+ const incompatible = createCodexProvider({
213
+ hookTransport: false,
214
+ resolveExecutable: () => "/fixture/old-codex",
215
+ buildEnv: () => ({ PATH: "/fixture" }),
216
+ getAuthStatus: () => ({ state: "unknown", reason: "not checked" }),
217
+ runCommand: (_binary, args) => {
218
+ if (args[0] === "--version") return { ok: true, output: "codex-cli 0.1.0", code: 0 };
219
+ if (args.join(" ") === "exec --help") return { ok: true, output: "--json", code: 0 };
220
+ return { ok: true, output: "--json", code: 0 };
221
+ },
222
+ });
223
+ assert.strictEqual(incompatible.compatibilityStatus().state, "incompatible");
224
+
225
+ const source = fs.readFileSync(path.join(__dirname, "core", "providers", "codex.js"), "utf8");
226
+ assert.ok(!source.includes('require("child_process")'), "provider adapter must return invocation data instead of spawning");
227
+
228
+ console.log("Codex provider OK");
@@ -0,0 +1,371 @@
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
+ const root = __dirname;
12
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-compaction-"));
13
+ const workspace = path.join(configDir, "workspace");
14
+ const alphaDir = path.join(workspace, "alpha");
15
+ const betaDir = path.join(workspace, "beta");
16
+ fs.mkdirSync(alphaDir, { recursive: true });
17
+ fs.mkdirSync(betaDir, { recursive: true });
18
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
19
+ process.env.WORKSPACE = workspace;
20
+ process.env.TELEGRAM_CHAT_ID = "fixture";
21
+ process.env.OPEN_CLAUDIA_TEST = "1";
22
+ process.env.CLAUDE_PATH = process.execPath;
23
+ process.env.CODEX_PATH = process.execPath;
24
+
25
+ const stateApi = require("./core/state");
26
+ const runner = require("./core/runner");
27
+
28
+ const state = stateApi.currentState();
29
+
30
+ function result(overrides = {}) {
31
+ return {
32
+ ok: true,
33
+ status: "succeeded",
34
+ terminalSeen: true,
35
+ text: "Summary\n\n=== CONDENSED SEED ===\nA sufficiently detailed condensed continuation that is longer than eighty characters for the fixture to accept.",
36
+ sessionId: "new-session",
37
+ usage: { input_tokens: 40, cached_input_tokens: 10, output_tokens: 4 },
38
+ usageScope: "session_total",
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function resetFixture(provider = "claude") {
44
+ state.currentSession = { name: "alpha", dir: alphaDir };
45
+ state.settings.backend = provider;
46
+ state.activeSessions = {
47
+ alpha: { claude: "old-claude", codex: "old-codex" },
48
+ beta: { claude: "beta-claude", codex: "beta-codex" },
49
+ };
50
+ state.usageBySession = {
51
+ [`alpha\0${provider}\0old-${provider}`]: {
52
+ ...stateApi.freshUsage(),
53
+ turns: 7,
54
+ inputTokens: 700,
55
+ liveContextTokens: 900,
56
+ },
57
+ };
58
+ state.pendingCompaction = null;
59
+ state.completedCompactions = {};
60
+ state.lastCompactedAt = 0;
61
+ state.isCompacting = false;
62
+ state.runningProcess = null;
63
+ state.preparingRun = false;
64
+ state.isFirstMessage = false;
65
+ stateApi.replaceProjectSessions(state.userId, "alpha", [
66
+ {
67
+ id: `old-${provider}`,
68
+ provider,
69
+ project: "alpha",
70
+ title: "Old session",
71
+ created: "2026-01-01T00:00:00.000Z",
72
+ lastUsed: "2026-01-01T00:00:00.000Z",
73
+ selectable: true,
74
+ },
75
+ ], { strict: true });
76
+ stateApi.saveState({ strict: true });
77
+ }
78
+
79
+ function serviceWith(runCapture, extra = {}) {
80
+ return runner.createCompactionService({
81
+ getState: () => state,
82
+ captureRunContext: runner.admitRunContext,
83
+ runCapture,
84
+ commitCompaction: stateApi.commitProviderCompaction,
85
+ archiveBrief: () => null,
86
+ appendDaySeed: () => {},
87
+ collectRepoFacts: () => null,
88
+ collectRecentTurns: () => null,
89
+ ...extra,
90
+ });
91
+ }
92
+
93
+ async function immutableSuccessProbe() {
94
+ resetFixture("claude");
95
+ state.isCompacting = true;
96
+ assert.strictEqual(stateApi.acquireRunLock(state), false, "foreground admission stays closed between compaction stages");
97
+ state.isCompacting = false;
98
+ const contexts = [];
99
+ const service = serviceWith(async (_prompt, _cwd, options) => {
100
+ assert.strictEqual(options.persist, false, "compaction provider stages have no ordinary capture persistence");
101
+ contexts.push(options.runContext);
102
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", "pointer stays old during both provider stages");
103
+ if (contexts.length === 1) {
104
+ state.currentSession = { name: "beta", dir: betaDir };
105
+ state.settings.backend = "codex";
106
+ state.isFirstMessage = true;
107
+ return result({ sessionId: "old-claude" });
108
+ }
109
+ return result({ sessionId: "new-claude" });
110
+ });
111
+ const compacted = await service.compact(alphaDir, { operationId: "compact-success" });
112
+ assert.strictEqual(compacted.compacted, true);
113
+ assert.strictEqual(contexts.length, 2);
114
+ assert.strictEqual(contexts[0].provider, "claude");
115
+ assert.strictEqual(contexts[0].project.name, "alpha");
116
+ assert.strictEqual(contexts[0].sessionId, "old-claude");
117
+ assert.ok(Number.isFinite(contexts[0].timeoutMs) && contexts[0].timeoutMs > 0);
118
+ assert.strictEqual(contexts[1].provider, "claude");
119
+ assert.strictEqual(contexts[1].project.name, "alpha");
120
+ assert.strictEqual(contexts[1].fresh, true);
121
+ assert.strictEqual(contexts[1].readOnly, true);
122
+ assert.strictEqual(contexts[1].permissionMode, "plan");
123
+ assert.strictEqual(contexts[1].maxTurns, 1);
124
+ assert.strictEqual(contexts[1].timeoutMs, contexts[0].timeoutMs);
125
+ const restrictedEnv = runner.decorateForegroundInvocation(
126
+ { env: { FIXTURE_SAFE: "1" } },
127
+ contexts[0],
128
+ { transcriptPaths: null },
129
+ ).env;
130
+ assert.strictEqual(restrictedEnv.OC_PROVIDER, "claude");
131
+ assert.ok(!Object.prototype.hasOwnProperty.call(restrictedEnv, "OC_SEND_URL"));
132
+ assert.ok(!Object.prototype.hasOwnProperty.call(restrictedEnv, "OC_CANONICAL_USER_ID"));
133
+ assert.ok(!Object.prototype.hasOwnProperty.call(restrictedEnv, "OC_LAST_SESSION_ID"));
134
+ assert.strictEqual(state.currentSession.name, "beta", "live project switch is preserved");
135
+ assert.strictEqual(state.settings.backend, "codex", "live provider switch is preserved");
136
+ assert.strictEqual(state.isFirstMessage, true, "the switched live tuple keeps its transient first-message state");
137
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "new-claude");
138
+ assert.strictEqual(stateApi.getProviderSession(state, "codex", "beta"), "beta-codex");
139
+ const history = stateApi.getProjectSessions(state.userId, "alpha", "claude");
140
+ assert.strictEqual(history.filter((entry) => entry.id === "new-claude").length, 1);
141
+ assert.deepStrictEqual(history[0].lineage, {
142
+ type: "compaction",
143
+ operationId: "compact-success",
144
+ previousSessionId: "old-claude",
145
+ newSessionId: "new-claude",
146
+ });
147
+ const usage = stateApi.getUsageForSession(state, "alpha", "claude", "new-claude");
148
+ assert.strictEqual(usage.turns, 0, "seed is not billed as a user turn");
149
+ assert.strictEqual(usage.compactionCheckpoint.previousSessionId, "old-claude");
150
+ assert.strictEqual(state.isCompacting, false);
151
+ }
152
+
153
+ async function failureProbe() {
154
+ resetFixture("claude");
155
+ state.runningProcess = { pid: 123 };
156
+ let busyCalls = 0;
157
+ const busy = await serviceWith(async () => { busyCalls += 1; return result(); }).compact(alphaDir);
158
+ assert.strictEqual(busy.compacted, false);
159
+ assert.strictEqual(busyCalls, 0, "manual compaction never overlaps an active provider run");
160
+ state.runningProcess = null;
161
+
162
+ resetFixture("claude");
163
+ let summaryCalls = 0;
164
+ const summaryFailure = serviceWith(async () => {
165
+ summaryCalls += 1;
166
+ return result({ ok: false, status: "failed", sessionId: "old-claude" });
167
+ });
168
+ const failedSummary = await summaryFailure.compact(alphaDir, { operationId: "compact-summary-failed" });
169
+ assert.strictEqual(failedSummary.compacted, false);
170
+ assert.strictEqual(summaryCalls, 1, "a failed summary never starts a seed");
171
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude");
172
+
173
+ for (const [label, seed] of [
174
+ ["failed", result({ ok: false, status: "failed", sessionId: "failed-new" })],
175
+ ["nonterminal", result({ terminalSeen: false, sessionId: "nonterminal-new" })],
176
+ ["missing-session", result({ sessionId: null })],
177
+ ]) {
178
+ resetFixture("claude");
179
+ let calls = 0;
180
+ const service = serviceWith(async () => (++calls === 1 ? result({ sessionId: "old-claude" }) : seed));
181
+ const compacted = await service.compact(alphaDir, { operationId: `compact-${label}` });
182
+ assert.strictEqual(compacted.compacted, false, label);
183
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", label);
184
+ assert.strictEqual(stateApi.getProjectSessions(state.userId, "alpha", "claude").length, 1, label);
185
+ }
186
+
187
+ resetFixture("claude");
188
+ let calls = 0;
189
+ let stateWrites = 0;
190
+ const persistenceFailure = serviceWith(
191
+ async () => (++calls === 1 ? result({ sessionId: "old-claude" }) : result({ sessionId: "persist-failed-new" })),
192
+ {
193
+ commitCompaction: (target, transaction) => stateApi.commitProviderCompaction(target, transaction, {
194
+ persistState(options) {
195
+ stateWrites += 1;
196
+ if (stateWrites === 2) throw Object.assign(new Error("fixture persistence failure"), { code: "FIXTURE_PERSISTENCE" });
197
+ return stateApi.saveState(options);
198
+ },
199
+ }),
200
+ },
201
+ );
202
+ const failed = await persistenceFailure.compact(alphaDir, { operationId: "compact-persist-failure" });
203
+ assert.strictEqual(failed.compacted, false);
204
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude");
205
+ assert.strictEqual(stateApi.getProjectSessions(state.userId, "alpha", "claude").length, 1);
206
+
207
+ resetFixture("claude");
208
+ calls = 0;
209
+ const rejectedHistory = serviceWith(
210
+ async () => (++calls === 1 ? result({ sessionId: "old-claude" }) : result({ sessionId: "history-rejected-new" })),
211
+ {
212
+ commitCompaction: (target, transaction) => stateApi.commitProviderCompaction(target, transaction, {
213
+ recordHistory: () => false,
214
+ }),
215
+ },
216
+ );
217
+ const historyFailed = await rejectedHistory.compact(alphaDir, { operationId: "compact-history-rejected" });
218
+ assert.strictEqual(historyFailed.compacted, false);
219
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude");
220
+ assert.deepStrictEqual(stateApi.getProjectSessions(state.userId, "alpha", "claude").map((entry) => entry.id), ["old-claude"]);
221
+ }
222
+
223
+ async function codexRestrictionProbe() {
224
+ resetFixture("codex");
225
+ const contexts = [];
226
+ const service = serviceWith(async (_prompt, _cwd, options) => {
227
+ contexts.push(options.runContext);
228
+ return contexts.length === 1
229
+ ? result({ sessionId: "old-codex" })
230
+ : result({ sessionId: "new-codex", usage: { input_tokens: 90, cached_input_tokens: 50, output_tokens: 5 } });
231
+ });
232
+ const compacted = await service.compact(alphaDir, { operationId: "compact-codex" });
233
+ assert.strictEqual(compacted.compacted, true);
234
+ const seed = contexts[1];
235
+ assert.strictEqual(seed.provider, "codex");
236
+ assert.strictEqual(seed.readOnly, true);
237
+ assert.strictEqual(seed.permissionMode, "plan");
238
+ assert.strictEqual(seed.maxTurns, null, "Codex maxTurns is honestly unsupported");
239
+ assert.ok(seed.outputSchemaPath && fs.existsSync(seed.outputSchemaPath));
240
+ const usage = stateApi.getUsageForSession(state, "alpha", "codex", "new-codex");
241
+ assert.deepStrictEqual(usage.providerUsageSnapshot, {
242
+ input_tokens: 90,
243
+ cached_input_tokens: 50,
244
+ output_tokens: 5,
245
+ });
246
+
247
+ const codexProvider = require("./core/providers/codex").createCodexProvider({
248
+ hookTransport: false,
249
+ resolveExecutable: () => process.execPath,
250
+ buildEnv: () => ({}),
251
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
252
+ });
253
+ const codexInvocation = codexProvider.buildCompactionInvocation({
254
+ coreInstructions: "fixture policy",
255
+ userPrompt: "acknowledge",
256
+ providerSettings: { permissionMode: "plan", budget: null, worktree: false },
257
+ fresh: true,
258
+ readOnly: true,
259
+ maxTurns: null,
260
+ outputSchemaPath: seed.outputSchemaPath,
261
+ imagePaths: [],
262
+ });
263
+ assert.ok(codexInvocation.args.includes("read-only"));
264
+ assert.ok(codexInvocation.args.includes("--output-schema"));
265
+ assert.ok(!codexInvocation.args.includes("--ephemeral"), "persisted Codex seed is resumable");
266
+ assert.ok(!codexInvocation.args.includes("--dangerously-bypass-approvals-and-sandbox"));
267
+
268
+ const claudeProvider = require("./core/providers/claude").createClaudeProvider({
269
+ hookTransport: false,
270
+ resolveExecutable: () => process.execPath,
271
+ buildEnv: () => ({}),
272
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
273
+ });
274
+ const claudeInvocation = claudeProvider.buildCompactionInvocation({
275
+ coreInstructions: "fixture policy",
276
+ userPrompt: "acknowledge",
277
+ providerSettings: { permissionMode: "plan", budget: null, worktree: false },
278
+ fresh: true,
279
+ readOnly: true,
280
+ maxTurns: 1,
281
+ imagePaths: [],
282
+ });
283
+ assert.deepStrictEqual(claudeInvocation.args.slice(claudeInvocation.args.indexOf("--max-turns"), claudeInvocation.args.indexOf("--max-turns") + 2), ["--max-turns", "1"]);
284
+ assert.deepStrictEqual(claudeInvocation.args.slice(claudeInvocation.args.indexOf("--permission-mode"), claudeInvocation.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
285
+ }
286
+
287
+ function duplicateCommitProbe() {
288
+ resetFixture("claude");
289
+ const transaction = {
290
+ operationId: "compact-duplicate",
291
+ canonicalUserId: state.userId,
292
+ project: "alpha",
293
+ provider: "claude",
294
+ previousSessionId: "old-claude",
295
+ newSessionId: "duplicate-new",
296
+ title: "Compacted fixture",
297
+ seedUsage: null,
298
+ };
299
+ const first = stateApi.commitProviderCompaction(state, transaction);
300
+ const second = stateApi.commitProviderCompaction(state, transaction);
301
+ assert.strictEqual(first.committed, true);
302
+ assert.strictEqual(second.duplicate, true);
303
+ assert.strictEqual(stateApi.getProjectSessions(state.userId, "alpha", "claude").filter((entry) => entry.id === "duplicate-new").length, 1);
304
+ }
305
+
306
+ function restartRecoveryProbe() {
307
+ resetFixture("claude");
308
+ const childScript = [
309
+ "const stateApi = require('./core/state');",
310
+ "const state = stateApi.currentState();",
311
+ "stateApi.commitProviderCompaction(state, {",
312
+ " operationId: 'compact-crash', canonicalUserId: state.userId, project: 'alpha', provider: 'claude',",
313
+ " previousSessionId: 'old-claude', newSessionId: 'crash-new', title: 'Crash fixture', seedUsage: null,",
314
+ "}, { faultInjector(point) { if (point === 'after-compaction-history') process.exit(73); } });",
315
+ ].join("\n");
316
+ const crashed = spawnSync(process.execPath, ["-e", childScript], { cwd: root, env: process.env, encoding: "utf8" });
317
+ assert.strictEqual(crashed.status, 73, crashed.stderr || crashed.stdout);
318
+
319
+ const recovered = spawnSync(process.execPath, ["-e", [
320
+ "const s = require('./core/state');",
321
+ "const state = s.currentState();",
322
+ "process.stdout.write(JSON.stringify({ pointer: s.getProviderSession(state, 'claude', 'alpha'), history: s.getProjectSessions(state.userId, 'alpha', 'claude').map(x => x.id), pending: state.pendingCompaction || null }));",
323
+ ].join("\n")], { cwd: root, env: process.env, encoding: "utf8" });
324
+ assert.strictEqual(recovered.status, 0, recovered.stderr || recovered.stdout);
325
+ assert.deepStrictEqual(JSON.parse(recovered.stdout), {
326
+ pointer: "old-claude",
327
+ history: ["old-claude"],
328
+ pending: null,
329
+ });
330
+
331
+ resetFixture("claude");
332
+ const committedCrashScript = [
333
+ "const stateApi = require('./core/state');",
334
+ "const state = stateApi.currentState();",
335
+ "stateApi.commitProviderCompaction(state, {",
336
+ " operationId: 'compact-committed-crash', canonicalUserId: state.userId, project: 'alpha', provider: 'claude',",
337
+ " previousSessionId: 'old-claude', newSessionId: 'committed-crash-new', title: 'Committed crash fixture', seedUsage: null,",
338
+ "}, { faultInjector(point) { if (point === 'after-compaction-state-commit') process.exit(74); } });",
339
+ ].join("\n");
340
+ const committedCrash = spawnSync(process.execPath, ["-e", committedCrashScript], { cwd: root, env: process.env, encoding: "utf8" });
341
+ assert.strictEqual(committedCrash.status, 74, committedCrash.stderr || committedCrash.stdout);
342
+ const finalized = spawnSync(process.execPath, ["-e", [
343
+ "const s = require('./core/state');",
344
+ "const state = s.currentState();",
345
+ "process.stdout.write(JSON.stringify({ pointer: s.getProviderSession(state, 'claude', 'alpha'), history: s.getProjectSessions(state.userId, 'alpha', 'claude').map(x => x.id), pending: state.pendingCompaction || null }));",
346
+ ].join("\n")], { cwd: root, env: process.env, encoding: "utf8" });
347
+ assert.strictEqual(finalized.status, 0, finalized.stderr || finalized.stdout);
348
+ assert.deepStrictEqual(JSON.parse(finalized.stdout), {
349
+ pointer: "committed-crash-new",
350
+ history: ["committed-crash-new", "old-claude"],
351
+ pending: null,
352
+ });
353
+ }
354
+
355
+ (async () => {
356
+ try {
357
+ assert.strictEqual(typeof runner.createCompactionService, "function");
358
+ assert.strictEqual(typeof stateApi.commitProviderCompaction, "function");
359
+ await immutableSuccessProbe();
360
+ await failureProbe();
361
+ await codexRestrictionProbe();
362
+ duplicateCommitProbe();
363
+ restartRecoveryProbe();
364
+ console.log("provider compaction OK");
365
+ } finally {
366
+ fs.rmSync(configDir, { recursive: true, force: true });
367
+ }
368
+ })().catch((error) => {
369
+ console.error(error);
370
+ process.exit(1);
371
+ });