@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
package/setup.js CHANGED
@@ -8,6 +8,12 @@ const crypto = require("crypto");
8
8
  const { execSync } = require("child_process");
9
9
  const Vault = require("./vault");
10
10
  const CONFIG_DIR = require("./config-dir");
11
+ const { ensureConfigDir } = require("./core/config-dir");
12
+ const { atomicWriteFileSync } = require("./core/fsutil");
13
+ const { inspectProviderConfiguration } = require("./core/provider-status");
14
+ const { ProviderRegistry } = require("./core/providers");
15
+ const { createClaudeProvider } = require("./core/providers/claude");
16
+ const { createCodexProvider } = require("./core/providers/codex");
11
17
 
12
18
  const ENV_FILE = path.join(CONFIG_DIR, ".env");
13
19
  const VAULT_FILE = path.join(CONFIG_DIR, "vault.enc");
@@ -16,8 +22,12 @@ const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
16
22
  const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
17
23
  const SETUP_STATE_FILE = path.join(CONFIG_DIR, ".setup-state.json");
18
24
 
19
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
20
- const ask = (q) => new Promise((r) => rl.question(q, r));
25
+ let rl = null;
26
+ function promptInterface() {
27
+ if (!rl) rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
+ return rl;
29
+ }
30
+ const ask = (q) => new Promise((r) => promptInterface().question(q, r));
21
31
 
22
32
  // ── Auth file helpers ──────────────────────────────────────────────
23
33
 
@@ -29,7 +39,7 @@ function loadAuth() {
29
39
  }
30
40
 
31
41
  function saveAuth(auth) {
32
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
42
+ atomicWriteFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { backup: true, mode: 0o600 });
33
43
  }
34
44
 
35
45
  // ── Setup state helpers ────────────────────────────────────────────
@@ -42,13 +52,25 @@ function loadSetupState() {
42
52
  }
43
53
 
44
54
  function saveSetupState(state) {
45
- fs.writeFileSync(SETUP_STATE_FILE, JSON.stringify(state, null, 2));
55
+ atomicWriteFileSync(SETUP_STATE_FILE, JSON.stringify(state, null, 2), { backup: true, mode: 0o600 });
46
56
  }
47
57
 
48
58
  function clearSetupState() {
49
59
  if (fs.existsSync(SETUP_STATE_FILE)) fs.unlinkSync(SETUP_STATE_FILE);
50
60
  }
51
61
 
62
+ function upgradeSetupState(value) {
63
+ const state = value && typeof value === "object"
64
+ ? { ...value, completedSteps: [...(value.completedSteps || [])], data: { ...(value.data || {}) } }
65
+ : { completedSteps: [], data: {} };
66
+ if (state.completedSteps.includes("prerequisites")
67
+ && (!Object.prototype.hasOwnProperty.call(state.data, "codexPath")
68
+ || !Object.prototype.hasOwnProperty.call(state.data, "defaultProvider"))) {
69
+ state.completedSteps = state.completedSteps.filter((step) => step !== "prerequisites");
70
+ }
71
+ return state;
72
+ }
73
+
52
74
  // ── Telegram helpers ───────────────────────────────────────────────
53
75
 
54
76
  function telegramGet(token, method, params = "") {
@@ -147,9 +169,56 @@ function findBinary(name) {
147
169
  }
148
170
 
149
171
  function findClaude() { return findBinary("claude"); }
172
+ function findCodex() { return findBinary("codex"); }
150
173
  function findWhisper() { return findBinary("whisper-cli"); }
151
174
  function findFfmpeg() { return findBinary("ffmpeg"); }
152
175
 
176
+ function inspectProviderSetup(options = {}) {
177
+ if (!options.paths) return inspectProviderConfiguration(options);
178
+ const resolved = {};
179
+ for (const providerId of ["claude", "codex"]) {
180
+ const value = String(options.paths[providerId] || "").trim();
181
+ if (!value) {
182
+ resolved[providerId] = null;
183
+ continue;
184
+ }
185
+ try {
186
+ fs.accessSync(value, fs.constants.X_OK);
187
+ resolved[providerId] = path.resolve(value);
188
+ } catch (_) {
189
+ resolved[providerId] = null;
190
+ }
191
+ }
192
+ const registry = new ProviderRegistry({ defaultProvider: options.defaultProvider || null });
193
+ registry.registerProvider(createClaudeProvider({ resolveExecutable: () => resolved.claude }));
194
+ registry.registerProvider(createCodexProvider({ resolveExecutable: () => resolved.codex }));
195
+ return inspectProviderConfiguration({
196
+ ...options,
197
+ registry,
198
+ executableStatus(providerId) {
199
+ const configured = String(options.paths[providerId] || "").trim();
200
+ if (resolved[providerId]) {
201
+ return { state: "available", executable: resolved[providerId], configured: true, reason: null };
202
+ }
203
+ return {
204
+ state: configured ? "missing" : "unconfigured",
205
+ executable: null,
206
+ configured: !!configured,
207
+ reason: configured
208
+ ? `Configured ${providerId} executable was not found`
209
+ : `${providerId} is not configured or installed`,
210
+ };
211
+ },
212
+ });
213
+ }
214
+
215
+ function providerStatusLine(provider) {
216
+ const availability = provider.availability.state;
217
+ if (availability !== "available") return `${provider.label}: ${availability}`;
218
+ const version = provider.compatibility.version ? ` ${provider.compatibility.version}` : "";
219
+ return `${provider.label}: ${provider.compatibility.state}${version}; auth ${provider.auth.state}`;
220
+ }
221
+
153
222
  function findWhisperModel() {
154
223
  const candidates = [
155
224
  "/opt/homebrew/share/whisper-cpp/models/ggml-base.en.bin",
@@ -285,7 +354,7 @@ async function setupDaemon(platform) {
285
354
  const serviceName = "claude-telegram-bot";
286
355
  const serviceFile = `/etc/systemd/system/${serviceName}.service`;
287
356
  const unit = `[Unit]
288
- Description=Claude Code Telegram Bot
357
+ Description=Open Claudia Provider Bot
289
358
  After=network.target
290
359
 
291
360
  [Service]
@@ -483,7 +552,7 @@ function updateEnvKey(key, value) {
483
552
  return line;
484
553
  });
485
554
  if (!found) updated.push(`${key}=${value}`);
486
- fs.writeFileSync(ENV_FILE, updated.join("\n"));
555
+ atomicWriteFileSync(ENV_FILE, updated.join("\n"), { backup: true, mode: 0o600 });
487
556
  }
488
557
 
489
558
  // ── Main setup (resumable) ─────────────────────────────────────────
@@ -497,6 +566,7 @@ const { validateKazee } = require("./core/kazee-probe");
497
566
  const KAZEE_DEFAULT_URL = "https://chat.inet.africa";
498
567
 
499
568
  async function main() {
569
+ ensureConfigDir(CONFIG_DIR);
500
570
  // Check if this is an auth subcommand
501
571
  const args = process.argv.slice(2);
502
572
  if (args.includes("--auth") || args.includes("auth")) {
@@ -504,10 +574,12 @@ async function main() {
504
574
  return;
505
575
  }
506
576
 
507
- console.log("\n Claude Code Telegram Bot — Setup\n");
577
+ console.log("\n Open Claudia — Setup\n");
508
578
 
509
579
  // Load or create state
510
- let state = loadSetupState() || { completedSteps: [], data: {} };
580
+ // Resume files from the Claude-only wizard must pass through the provider
581
+ // step once before the new config keys can be serialized safely.
582
+ let state = upgradeSetupState(loadSetupState());
511
583
  const resuming = state.completedSteps.length > 0;
512
584
 
513
585
  if (resuming) {
@@ -525,27 +597,71 @@ async function main() {
525
597
  if (!state.completedSteps.includes("prerequisites")) {
526
598
  console.log("Checking prerequisites...\n");
527
599
 
528
- const claudePath = findClaude();
529
- if (!claudePath) {
530
- console.log(" Claude Code CLI not found. Install it first:");
531
- console.log(" https://docs.anthropic.com/en/docs/claude-code");
532
- process.exit(1);
600
+ const providerReport = inspectProviderSetup();
601
+ for (const provider of providerReport.providers) {
602
+ console.log(` ${providerStatusLine(provider)}`);
603
+ if (provider.recovery) console.log(` Recovery: ${provider.recovery.split("\n")[0]}`);
533
604
  }
534
- console.log(` Claude CLI: ${claudePath}`);
605
+ console.log("");
535
606
 
536
- try {
537
- execSync(`"${claudePath}" -p "say ok" --max-budget-usd 0.01 --output-format text 2>&1`, {
538
- encoding: "utf-8", timeout: 30000,
539
- });
540
- console.log(" Claude auth: OK");
541
- } catch (e) {
542
- const errMsg = (e.stderr || e.stdout || e.message || "").toLowerCase();
543
- if (errMsg.includes("auth") || errMsg.includes("login") || errMsg.includes("api key") || errMsg.includes("unauthorized")) {
544
- console.log(" Claude auth: NOT LOGGED IN");
545
- console.log(" Run 'claude auth' or 'claude login' to authenticate first.");
546
- process.exit(1);
607
+ const autoProviders = providerReport.providers
608
+ .filter((provider) => provider.availability.state === "available"
609
+ && provider.compatibility.state === "compatible")
610
+ .map((provider) => provider.id);
611
+ const choice = (await ask(" Providers to configure (auto/claude/codex/both/none) [auto]: "))
612
+ .trim().toLowerCase() || "auto";
613
+ const wanted = choice === "none" ? []
614
+ : choice === "both" ? ["claude", "codex"]
615
+ : choice === "claude" || choice === "codex" ? [choice]
616
+ : autoProviders;
617
+ const detectedById = Object.fromEntries(providerReport.providers.map((provider) => [
618
+ provider.id,
619
+ provider.availability.executable || null,
620
+ ]));
621
+ const providerPaths = { claude: null, codex: null };
622
+ for (const providerId of wanted) {
623
+ let detected = detectedById[providerId]
624
+ || state.data?.[providerId === "claude" ? "claudePath" : "codexPath"]
625
+ || (providerId === "claude" ? findClaude() : findCodex());
626
+ if (!detected) {
627
+ const envKey = providerId === "claude" ? "CLAUDE_PATH" : "CODEX_PATH";
628
+ const entered = (await ask(` ${envKey} (blank to leave unconfigured): `)).trim();
629
+ if (entered) {
630
+ try {
631
+ fs.accessSync(entered, fs.constants.X_OK);
632
+ detected = path.resolve(entered);
633
+ } catch (_) {
634
+ console.log(` ${entered} is not executable; ${providerId} remains unconfigured.`);
635
+ }
636
+ }
637
+ }
638
+ providerPaths[providerId] = detected || null;
639
+ }
640
+ const finalProviderReport = inspectProviderSetup({ paths: providerPaths });
641
+ for (const provider of finalProviderReport.providers) {
642
+ if (provider.availability.state === "available" && provider.compatibility.state !== "compatible") {
643
+ console.log(` ${providerStatusLine(provider)}`);
644
+ if (provider.recovery) console.log(` Recovery: ${provider.recovery.split("\n")[0]}`);
547
645
  }
548
- console.log(" Claude auth: OK");
646
+ }
647
+ const configuredProviders = finalProviderReport.providers
648
+ .filter((provider) => provider.availability.state === "available"
649
+ && provider.compatibility.state === "compatible")
650
+ .map((provider) => provider.id);
651
+ let defaultProvider = configuredProviders[0] || null;
652
+ if (configuredProviders.length > 1) {
653
+ const reportedDefault = providerReport.defaultProvider.providerId;
654
+ const suggestedDefault = configuredProviders.includes(reportedDefault)
655
+ ? reportedDefault
656
+ : configuredProviders[0];
657
+ const answer = (await ask(` Default provider (claude/codex) [${suggestedDefault}]: `))
658
+ .trim().toLowerCase();
659
+ const proposed = answer || suggestedDefault;
660
+ defaultProvider = configuredProviders.includes(proposed) ? proposed : configuredProviders[0];
661
+ }
662
+ if (!configuredProviders.length) {
663
+ console.log(" No model provider configured. Setup, doctor, health, and the web UI will still work;");
664
+ console.log(" model turns will return NO_AVAILABLE_PROVIDER until Claude Code or OpenAI Codex is configured.\n");
549
665
  }
550
666
 
551
667
  const platform = detectPlatform();
@@ -592,7 +708,9 @@ async function main() {
592
708
  }
593
709
  }
594
710
 
595
- state.data.claudePath = claudePath;
711
+ state.data.claudePath = providerPaths.claude || "";
712
+ state.data.codexPath = providerPaths.codex || "";
713
+ state.data.defaultProvider = defaultProvider || "";
596
714
  state.data.platform = platform;
597
715
  state.data.ffmpegPath = ffmpegPath || "";
598
716
  state.data.whisperPath = whisperPath || "";
@@ -648,7 +766,7 @@ async function main() {
648
766
 
649
767
  // Send confirmation
650
768
  console.log(" Sending test message...");
651
- const sent = await sendMessage(token, userInfo.chatId, "Setup verified! Your Claude Code bot is connected.");
769
+ const sent = await sendMessage(token, userInfo.chatId, "Setup verified! Your Open Claudia bot is connected.");
652
770
  if (sent) {
653
771
  console.log(" Test message sent! Check your Telegram.\n");
654
772
  } else {
@@ -747,6 +865,15 @@ async function main() {
747
865
  `TELEGRAM_CHAT_ID=${d.chatId}`,
748
866
  `WORKSPACE=${d.workspace}`,
749
867
  `CLAUDE_PATH=${d.claudePath}`,
868
+ `CODEX_PATH=${d.codexPath}`,
869
+ `DEFAULT_PROVIDER=${d.defaultProvider}`,
870
+ "UTILITY_PROVIDER=active",
871
+ "PROVIDER_FALLBACKS=",
872
+ "DREAM_PROVIDER=",
873
+ "MEMORY_PROVIDER=",
874
+ "RECALL_PROVIDER=",
875
+ "ENFORCER_PROVIDER=",
876
+ "SUBAGENT_PROVIDER=",
750
877
  `WHISPER_CLI=${d.whisperPath}`,
751
878
  `WHISPER_MODEL=${d.whisperModel}`,
752
879
  `FFMPEG=${d.ffmpegPath}`,
@@ -763,9 +890,30 @@ async function main() {
763
890
  envLines.push(`KAZEE_BOT_USER_ID=${d.kazee.botUserId || ""}`);
764
891
  envLines.push(`KAZEE_OWNER_USER_ID=${d.kazee.ownerUserId || ""}`);
765
892
  }
766
- const env = envLines.join("\n");
767
-
768
- fs.writeFileSync(ENV_FILE, env);
893
+ const existingEnv = fs.existsSync(ENV_FILE)
894
+ ? fs.readFileSync(ENV_FILE, "utf8").split(/\r?\n/).reduce((result, line) => {
895
+ const index = line.indexOf("=");
896
+ if (index > 0) result[line.slice(0, index).trim()] = line.slice(index + 1).trim();
897
+ return result;
898
+ }, {})
899
+ : {};
900
+ const mergedEnv = { ...existingEnv };
901
+ const preserveExistingPolicy = new Set([
902
+ "UTILITY_PROVIDER", "PROVIDER_FALLBACKS", "DREAM_PROVIDER", "MEMORY_PROVIDER",
903
+ "RECALL_PROVIDER", "ENFORCER_PROVIDER", "SUBAGENT_PROVIDER",
904
+ ]);
905
+ for (const line of envLines) {
906
+ const index = line.indexOf("=");
907
+ const key = line.slice(0, index);
908
+ const value = line.slice(index + 1);
909
+ // A resumed/second setup must never erase the other provider's existing
910
+ // credential/config merely because its prompt was left blank.
911
+ if (preserveExistingPolicy.has(key) && Object.prototype.hasOwnProperty.call(existingEnv, key)) continue;
912
+ if (!value && Object.prototype.hasOwnProperty.call(existingEnv, key)) continue;
913
+ mergedEnv[key] = value;
914
+ }
915
+ const env = Object.entries(mergedEnv).map(([key, value]) => `${key}=${value}`).join("\n");
916
+ atomicWriteFileSync(ENV_FILE, env, { backup: true, mode: 0o600 });
769
917
  console.log(` Config saved: ${ENV_FILE}\n`);
770
918
 
771
919
  if (!fs.existsSync(CRONS_FILE)) fs.writeFileSync(CRONS_FILE, "[]");
@@ -794,13 +942,25 @@ async function main() {
794
942
  // Done — clean up state file
795
943
  clearSetupState();
796
944
  console.log("\n Setup complete! Start chatting with your bot in Telegram.\n");
797
- rl.close();
945
+ if (rl) rl.close();
798
946
  }
799
947
 
800
948
  // Allow running auth directly: node setup.js auth
801
- module.exports = { runAuth, loadAuth, saveAuth, AUTH_FILE };
802
-
803
- main().catch((e) => {
804
- console.error(e);
805
- process.exit(1);
806
- });
949
+ module.exports = {
950
+ runAuth,
951
+ loadAuth,
952
+ saveAuth,
953
+ AUTH_FILE,
954
+ inspectProviderSetup,
955
+ providerStatusLine,
956
+ upgradeSetupState,
957
+ main,
958
+ };
959
+
960
+ if (require.main === module) {
961
+ main().catch((e) => {
962
+ if (rl) rl.close();
963
+ console.error(e);
964
+ process.exit(1);
965
+ });
966
+ }
package/soul.md ADDED
@@ -0,0 +1,39 @@
1
+ # Soul
2
+
3
+ You are Sumeet's personal AI assistant: Open Claudia running the selected coding-agent provider, available 24/7 through his configured chat channels.
4
+
5
+ ## Identity
6
+ - Name: Open Claudia
7
+ - Tone: Direct, concise, technical. Like a sharp senior engineer who's also a good friend.
8
+ - Never over-explain. Sumeet is technical — he gets it.
9
+ - Mobile-first: keep messages short. Send files for anything long.
10
+
11
+ ## About Sumeet
12
+ - Full-stack developer / founder
13
+ - Works across many projects in ~/Workspace
14
+ - Tech stack: Node.js, React/Next.js, MongoDB, Kubernetes, Docker
15
+ - Uses Cursor IDE and Claude Code CLI daily
16
+ - Communicates via Telegram on mobile when away from desk
17
+
18
+ ## Key Projects
19
+ - **crm** — Kazee CRM platform (monorepo: crm-backend, frontend, helpdesk-frontend, spaces-backend, spaces-frontend, workflow-kanban). MongoDB, Node.js backend, Next.js frontends.
20
+ - **metriq / metriq-api** — Metrics/analytics platform
21
+ - **ticket-central** — Ticketing system
22
+ - **hr-hub** — HR management
23
+ - **kazee-connect-subscription-service** — Subscription/billing service
24
+ - **k8s / kubernetes-applications / kazee-infratructure** — Kubernetes deployment configs
25
+ - **janus-devops / janus-webrtc** — WebRTC infrastructure
26
+
27
+ ## Working Style
28
+ - Prefers action over discussion. Do the thing, then explain what you did.
29
+ - Likes plans for big changes, but hates over-planning small ones.
30
+ - Appreciates when you proactively flag issues or suggest improvements.
31
+ - Send files directly via Telegram when output is long.
32
+
33
+ ## What You Can Do
34
+ - Write, edit, and refactor code across all projects
35
+ - Run commands, tests, builds, deployments
36
+ - Send files, images, code snippets directly to Telegram
37
+ - Run scheduled checks and report results
38
+ - Monitor git status, PRs, deployments
39
+ - Help with architecture decisions and code reviews
@@ -86,6 +86,11 @@ const reused = review.applyAction({
86
86
  });
87
87
  assert.strictEqual(reused.appliedTo, "chat-mobile", "update records the cross-project reuse");
88
88
  assert.deepStrictEqual(packs.readPack("mobile-app-deploy").applied_on, ["spaces", "chat-mobile"], "applied_on grew");
89
+ const abilityFile = path.join(process.env.PACKS_DIR, "mobile-app-deploy", "PACK.md");
90
+ assert.ok(fs.existsSync(`${abilityFile}.bak`), "reviewer writes retain the previous complete pack backup");
91
+ assert.strictEqual(fs.statSync(abilityFile).mode & 0o777, 0o600, "reviewer memory files stay owner-only");
92
+ assert.deepStrictEqual(fs.readdirSync(path.dirname(abilityFile)).filter((name) => name.endsWith(".tmp")), [],
93
+ "atomic reviewer writes leave no partial temporary file");
89
94
 
90
95
  graph.syncFromCorpus(packs, entStub);
91
96
  edges = graph.allEdges();
@@ -7,6 +7,7 @@ const assert = require("assert");
7
7
  const fs = require("fs");
8
8
  const os = require("os");
9
9
  const path = require("path");
10
+ const { spawn } = require("child_process");
10
11
 
11
12
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "approval-async-test-"));
12
13
  process.env.APPROVALS_DIR = path.join(tmp, "approvals");
@@ -48,6 +49,30 @@ const approvals = require("./core/approvals");
48
49
  assert.strictEqual(replay.ok, false, "second consume refused (single use)");
49
50
  assert.ok(/already consumed/.test(replay.reason));
50
51
 
52
+ const concurrent = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
53
+ approvals.decide(concurrent.id, "approved", "c1");
54
+ const consumeInChild = () => new Promise((resolve, reject) => {
55
+ const source = [
56
+ `const approvals = require(${JSON.stringify(path.join(__dirname, "core", "approvals.js"))});`,
57
+ `process.stdout.write(JSON.stringify(approvals.consume(${JSON.stringify(concurrent.id)}, ${JSON.stringify(CMD)})));`,
58
+ ].join("\n");
59
+ const child = spawn(process.execPath, ["-e", source], {
60
+ env: { ...process.env, APPROVALS_DIR: approvals.APPROVALS_DIR },
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ });
63
+ let stdout = "";
64
+ let stderr = "";
65
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
66
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
67
+ child.once("error", reject);
68
+ child.once("close", (code) => {
69
+ if (code !== 0) reject(new Error(stderr || `approval child exited ${code}`));
70
+ else resolve(JSON.parse(stdout));
71
+ });
72
+ });
73
+ const concurrentResults = await Promise.all([consumeInChild(), consumeInChild()]);
74
+ assert.strictEqual(concurrentResults.filter((result) => result.ok).length, 1, "concurrent redemption has exactly one winner");
75
+
51
76
  // ── unknown id fails closed ──
52
77
  assert.strictEqual(approvals.consume("apr_nope", CMD).ok, false, "unknown id refused");
53
78
 
@@ -62,11 +87,45 @@ const approvals = require("./core/approvals");
62
87
  assert.strictEqual(expired.ok, false, "expired token refused");
63
88
  assert.ok(/expired/.test(expired.reason));
64
89
 
65
- // ── long commands: stored payload is capped at 1000 chars and matching respects the cap ──
66
- const longCmd = "open-claudia tool run fleet purge " + "x".repeat(1200);
67
- const lrec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: longCmd, channelId: "c1", adapter: "tg" });
90
+ // ── command binding: the whole visible payload is hashed. Commands too long
91
+ // to display completely are rejected rather than approving a hidden suffix.
92
+ const boundCmd = "open-claudia tool run fleet purge " + "x".repeat(400);
93
+ const lrec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: boundCmd, channelId: "c1", adapter: "tg" });
94
+ assert.strictEqual(lrec.command, boundCmd, "the exact approved command is retained");
95
+ assert.match(lrec.commandHash, /^[a-f0-9]{64}$/, "the approval carries a full-payload digest");
68
96
  approvals.decide(lrec.id, "approved", "c1");
69
- assert.strictEqual(approvals.consume(lrec.id, longCmd).ok, true, "capped command still matches its own approval");
97
+ const changedSuffix = `${boundCmd.slice(0, -8)}DIFFERENT`;
98
+ assert.strictEqual(approvals.consume(lrec.id, changedSuffix).ok, false, "a suffix mismatch is refused");
99
+ assert.strictEqual(approvals.consume(lrec.id, boundCmd).ok, true, "the full exact command still redeems");
100
+ assert.throws(
101
+ () => approvals.create({ tool: "fleet", command: "open-claudia tool run fleet purge " + "x".repeat(1200) }),
102
+ (error) => error && error.code === "APPROVAL_PAYLOAD_TOO_LARGE",
103
+ "an approval never hides an undisplayed command suffix",
104
+ );
105
+
106
+ const legacy = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
107
+ approvals.decide(legacy.id, "approved", "c1");
108
+ const legacyFile = path.join(approvals.APPROVALS_DIR, `${legacy.id}.json`);
109
+ const legacyRecord = JSON.parse(fs.readFileSync(legacyFile, "utf8"));
110
+ delete legacyRecord.commandHash;
111
+ fs.writeFileSync(legacyFile, JSON.stringify(legacyRecord));
112
+ assert.strictEqual(approvals.consume(legacy.id, CMD).ok, true, "safe short pre-hash approvals remain redeemable");
113
+
114
+ const unsafeLegacy = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
115
+ approvals.decide(unsafeLegacy.id, "approved", "c1");
116
+ const unsafeLegacyFile = path.join(approvals.APPROVALS_DIR, `${unsafeLegacy.id}.json`);
117
+ const unsafeLegacyRecord = JSON.parse(fs.readFileSync(unsafeLegacyFile, "utf8"));
118
+ delete unsafeLegacyRecord.commandHash;
119
+ unsafeLegacyRecord.command = "x".repeat(600);
120
+ fs.writeFileSync(unsafeLegacyFile, JSON.stringify(unsafeLegacyRecord));
121
+ assert.strictEqual(
122
+ approvals.consume(unsafeLegacy.id, unsafeLegacyRecord.command).ok,
123
+ false,
124
+ "legacy approvals with an undisplayed suffix require a fresh decision",
125
+ );
126
+
127
+ const quotedCommand = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "fleet", "purge", "node one", "it's-safe"]);
128
+ assert.strictEqual(quotedCommand, "open-claudia tool run fleet purge 'node one' 'it'\"'\"'s-safe'");
70
129
 
71
130
  // ── waitForDecision default fast-path window is 90s (not the old 5 min) ──
72
131
  const src = fs.readFileSync(path.join(__dirname, "core", "approvals.js"), "utf-8");