@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/bin/cli.js CHANGED
@@ -11,22 +11,17 @@ const botDir = path.join(__dirname, "..");
11
11
  const configDir = require(path.join(botDir, "config-dir"));
12
12
  const health = require(path.join(botDir, "health"));
13
13
 
14
- function getBotFile() {
15
- const modeFile = path.join(configDir, ".bot-mode");
16
- try {
17
- const mode = fs.readFileSync(modeFile, "utf-8").trim();
18
- if (mode === "agent") return "bot-agent.js";
19
- } catch (e) {}
20
- return "bot.js";
14
+ function getBotMode() {
15
+ return require(path.join(botDir, "core", "side-chat")).readRuntimeMode(configDir);
21
16
  }
22
17
 
23
- function startBot(botFile, args) {
18
+ function startBot(args) {
24
19
  // Start web UI alongside bot if WEB_UI=true or --web flag
25
20
  if (process.env.WEB_UI === "true" || args.includes("--web")) {
26
21
  const { startWebServer } = require(path.join(botDir, "web.js"));
27
22
  startWebServer();
28
23
  }
29
- require(path.join(botDir, botFile));
24
+ require(path.join(botDir, "bot.js"));
30
25
  }
31
26
 
32
27
  function sendViaLoopback(kind, restArgs) {
@@ -52,7 +47,7 @@ function sendViaLoopback(kind, restArgs) {
52
47
  if (info) { sendUrl = info.url; sendToken = info.token; }
53
48
  }
54
49
  if (!sendUrl || !sendToken || !channelId || !adapterId) {
55
- console.error("No active chat context. This command only works from inside a running Claude task spawned by the bot.");
50
+ console.error("No active chat context. This command only works from inside an Open Claudia agent task spawned by the bot.");
56
51
  process.exit(2);
57
52
  }
58
53
 
@@ -132,13 +127,17 @@ function findBotProcesses() {
132
127
 
133
128
  switch (command) {
134
129
  case "setup":
135
- require(path.join(botDir, "setup.js"));
130
+ require(path.join(botDir, "setup.js")).main().catch((error) => {
131
+ console.error(error.stack || error.message);
132
+ process.exitCode = 1;
133
+ });
136
134
  break;
137
135
 
138
- // PreToolUse hook target (tool-first deny-gate, 5b). Claude pipes the tool
139
- // payload on stdin; exit 2 + stderr = deny (message fed back to the model),
140
- // exit 0 = allow. Every failure mode fails OPEN — a broken gate must never
141
- // brick the agent's shell.
136
+ // Provider-neutral PreToolUse target. Claude and Codex both pipe the Bash
137
+ // payload on stdin; exit 2 + stderr denies the command and exit 0 allows it.
138
+ // Transport initialization is mandatory in the provider adapters. Payload
139
+ // compatibility remains fail-open so an unknown future event shape cannot
140
+ // brick every shell command.
142
141
  case "deny-gate-hook": {
143
142
  let code = 0;
144
143
  try {
@@ -172,7 +171,7 @@ switch (command) {
172
171
 
173
172
  case "start": {
174
173
  const skipHealthCheck = args.includes("--skip-health") || args.includes("--force");
175
- const botFile = getBotFile();
174
+ const botMode = getBotMode();
176
175
 
177
176
  // Run health checks before starting (unless skipped)
178
177
  if (!skipHealthCheck) {
@@ -187,15 +186,15 @@ switch (command) {
187
186
  console.log("Warnings:");
188
187
  for (const w of results.warnings) console.log(` - ${w}`);
189
188
  }
190
- console.log(`Starting Open Claudia (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
191
- startBot(botFile, args);
189
+ console.log(`Starting Open Claudia (${botMode} mode, modular runtime)...`);
190
+ startBot(args);
192
191
  }).catch((err) => {
193
192
  console.error("Health check failed:", err.message);
194
193
  process.exit(1);
195
194
  });
196
195
  } else {
197
- console.log(`Starting Open Claudia (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
198
- startBot(botFile, args);
196
+ console.log(`Starting Open Claudia (${botMode} mode, modular runtime)...`);
197
+ startBot(args);
199
198
  }
200
199
  break;
201
200
  }
@@ -215,9 +214,9 @@ switch (command) {
215
214
  const { startWebServer: startWeb, isConfigured } = require(path.join(botDir, "web.js"));
216
215
  startWeb();
217
216
  if (isConfigured()) {
218
- const botFile = getBotFile();
219
- console.log(`Bot config found. Starting bot (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
220
- require(path.join(botDir, botFile));
217
+ const botMode = getBotMode();
218
+ console.log(`Bot config found. Starting bot (${botMode} mode, modular runtime)...`);
219
+ require(path.join(botDir, "bot.js"));
221
220
  } else {
222
221
  console.log("No config found. Complete setup in the web UI.");
223
222
  }
@@ -232,7 +231,10 @@ switch (command) {
232
231
  }
233
232
  // Legacy: open-claudia auth (no subcommand) → setup.js auth mode
234
233
  process.argv = [process.argv[0], process.argv[1], "--auth"];
235
- require(path.join(botDir, "setup.js"));
234
+ require(path.join(botDir, "setup.js")).main().catch((error) => {
235
+ console.error(error.stack || error.message);
236
+ process.exitCode = 1;
237
+ });
236
238
  break;
237
239
  }
238
240
 
@@ -419,12 +421,12 @@ Memory tools:
419
421
  open-claudia dream [--dry-run] Run the memory consolidation pass now
420
422
 
421
423
  Background work (only inside an active bot-spawned task):
422
- open-claudia schedule-wakeup <when> "<prompt>" One-shot future wake-up; resumes session
423
- open-claudia cron-add "<sched>" "<prompt>" Recurring cron job
424
+ open-claudia schedule-wakeup <when> "<prompt>" One-shot; resumes the captured selected provider session
425
+ open-claudia cron-add "<sched>" "<prompt>" Recurring job pinned to the selected provider session
424
426
  open-claudia cron-list List all wakeups + crons on this channel
425
427
  open-claudia cron-remove <id> Remove a scheduled job
426
428
  open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
427
- open-claudia agent "<prompt>" [--role X] Spawn a throwaway sub-agent for research
429
+ open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
428
430
 
429
431
  Cluster control (self-management of this bot's own deployment via the broker):
430
432
  open-claudia cluster status|logs|restart|start|stop|scale <0|1>|sync
@@ -438,7 +440,7 @@ Multi-user / cross-channel:
438
440
 
439
441
  Start options:
440
442
  --web Also start the web UI
441
- --quick Skip slow health checks (Claude auth, Telegram API)
443
+ --quick Skip slow provider auth and channel API checks
442
444
  --skip-health Bypass all health checks (not recommended)
443
445
 
444
446
  Health options:
package/bin/dream.js CHANGED
@@ -10,8 +10,8 @@ Run the dream memory-consolidation pass now.
10
10
 
11
11
  open-claudia dream [--dry-run]
12
12
 
13
- Reviews all context packs and entities with a stronger model (DREAM_MODEL,
14
- default sonnet): merges duplicate packs, builds umbrella/parent trees,
13
+ Reviews all context packs and entities with the configured high-tier utility
14
+ provider: merges duplicate packs, builds umbrella/parent trees,
15
15
  tightens descriptions and tags, dedupes entities, cross-links notes, and
16
16
  may gently evolve the persona. Merged-away packs/entities are backed up
17
17
  under ~/.open-claudia/backup/dream-<stamp>/ first.
@@ -24,17 +24,11 @@ async function run(args) {
24
24
  const dream = require(path.join(__dirname, "..", "core", "dream"));
25
25
 
26
26
  if (args.includes("--dry-run")) {
27
- const { spawnSubagent } = require(path.join(__dirname, "..", "core", "subagent"));
28
27
  console.error("Dreaming (dry run)… this can take a minute or two.");
29
28
  try {
30
- const { text } = await spawnSubagent(dream.buildDreamPrompt(), {
31
- model: dream.DREAM_MODEL,
32
- timeoutMs: 8 * 60 * 1000,
33
- systemPrompt: "You are a background memory consolidation process. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
34
- });
35
- const decision = dream.parseDream(text);
36
- if (!decision) { console.error("Unparseable model output:"); console.log(text); process.exit(1); }
37
- console.log(JSON.stringify(decision, null, 2));
29
+ const preview = await dream.previewDream();
30
+ console.error(`Dream provider: ${preview.provider || "unknown"}/${preview.model || "default"}`);
31
+ console.log(JSON.stringify(preview.decision, null, 2));
38
32
  process.exit(0);
39
33
  } catch (e) { console.error(`dream failed: ${e.message}`); process.exit(1); }
40
34
  }
@@ -31,6 +31,11 @@ function context() {
31
31
  const info = readLoopbackInfo();
32
32
  if (info) { sendUrl = info.url; sendToken = info.token; }
33
33
  }
34
+ let providerSettings = {};
35
+ try {
36
+ const parsed = JSON.parse(process.env.OC_PROVIDER_SETTINGS || "{}");
37
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) providerSettings = parsed;
38
+ } catch (_) {}
34
39
  return {
35
40
  sendUrl,
36
41
  sendToken,
@@ -38,8 +43,16 @@ function context() {
38
43
  adapterId: process.env.OC_CHANNEL_ADAPTER,
39
44
  userId: process.env.OC_CHANNEL_USER_ID || null,
40
45
  canonicalUserId: process.env.OC_CANONICAL_USER_ID || null,
41
- sessionId: process.env.OC_LAST_SESSION_ID || null,
42
- sessionKey: process.env.OC_LAST_SESSION_KEY || null,
46
+ provider: process.env.OC_PROVIDER || null,
47
+ providerSettings,
48
+ project: process.env.OC_PROJECT || null,
49
+ projectDir: process.env.OC_PROJECT_DIR || null,
50
+ runId: process.env.OC_RUN_ID || null,
51
+ sessionId: process.env.OC_SESSION_ID || process.env.OC_LAST_SESSION_ID || null,
52
+ previousSessionId: process.env.OC_PREVIOUS_SESSION_ID || null,
53
+ occurrenceId: process.env.OC_OCCURRENCE_ID || null,
54
+ scheduledJobId: process.env.OC_SCHEDULED_JOB_ID || null,
55
+ originRunId: process.env.OC_ORIGIN_RUN_ID || null,
43
56
  };
44
57
  }
45
58
 
@@ -55,14 +68,25 @@ function requireContext() {
55
68
  function postJson(kind, body) {
56
69
  const ctx = requireContext();
57
70
  const u = new URL(ctx.sendUrl);
71
+ const requestedProject = body && body.project ? String(body.project) : ctx.project;
72
+ const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
58
73
  const payload = JSON.stringify({
74
+ ...body,
59
75
  channelId: ctx.channelId,
60
76
  adapter: ctx.adapterId,
61
77
  userId: ctx.userId,
62
78
  canonicalUserId: ctx.canonicalUserId,
63
- sessionId: ctx.sessionId,
64
- sessionKey: ctx.sessionKey,
65
- ...body,
79
+ provider: ctx.provider,
80
+ providerSettings: ctx.providerSettings,
81
+ project: requestedProject,
82
+ projectDir: sameProject ? ctx.projectDir : null,
83
+ originProject: ctx.project,
84
+ originProjectDir: ctx.projectDir,
85
+ originRunId: ctx.runId,
86
+ sessionId: sameProject ? ctx.sessionId : null,
87
+ previousSessionId: sameProject ? ctx.previousSessionId : null,
88
+ parentOccurrenceId: ctx.occurrenceId,
89
+ parentScheduledJobId: ctx.scheduledJobId,
66
90
  });
67
91
  return new Promise((resolve, reject) => {
68
92
  const req = http.request({
package/bin/schedule.js CHANGED
@@ -15,8 +15,9 @@ Crons (recurring):
15
15
  open-claudia cron-list # all wakeups + crons for this channel
16
16
  open-claudia cron-remove <id> # remove by id from cron-list
17
17
 
18
- Each fire wakes the agent up in the same channel and resumes the Claude session
19
- that was active when the schedule was created (so context carries over).
18
+ Each fire wakes the agent in the same channel and resumes the selected provider
19
+ session captured when the schedule was created. Provider/project switches made
20
+ later do not retarget already-scheduled work.
20
21
  `;
21
22
 
22
23
  const DUR = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i;
@@ -68,7 +69,9 @@ async function runScheduleWakeup(args) {
68
69
  const prompt = rest.slice(1).join(" ");
69
70
  if (!prompt) { console.error("Missing prompt."); process.exit(2); }
70
71
  try {
71
- const res = await postJson("schedule-wakeup", { fireAt: when, prompt, label: label || prompt.slice(0, 60), project });
72
+ const request = { fireAt: when, prompt, label: label || prompt.slice(0, 60) };
73
+ if (project) request.project = project;
74
+ const res = await postJson("schedule-wakeup", request);
72
75
  console.log(`Scheduled wakeup ${res.job.id} for ${new Date(when).toISOString()}.`);
73
76
  process.exit(0);
74
77
  } catch (e) { console.error(e.message); process.exit(1); }
@@ -83,7 +86,9 @@ async function runCronAdd(args) {
83
86
  const prompt = rest.slice(1).join(" ");
84
87
  if (!schedule || !prompt) { console.error("Usage: cron-add \"<schedule>\" \"<prompt>\""); process.exit(2); }
85
88
  try {
86
- const res = await postJson("cron-add", { schedule, prompt, label: label || prompt.slice(0, 60), project });
89
+ const request = { schedule, prompt, label: label || prompt.slice(0, 60) };
90
+ if (project) request.project = project;
91
+ const res = await postJson("cron-add", request);
87
92
  console.log(`Added cron ${res.job.id} (${schedule}).`);
88
93
  process.exit(0);
89
94
  } catch (e) { console.error(e.message); process.exit(1); }
@@ -93,8 +98,17 @@ async function runCronList() {
93
98
  try {
94
99
  const res = await postJson("job-list", {});
95
100
  const list = res.jobs || [];
96
- if (list.length === 0) { console.log("No jobs."); process.exit(0); }
101
+ const archived = res.archived || [];
102
+ if (list.length === 0 && archived.length === 0) { console.log("No jobs."); process.exit(0); }
97
103
  for (const j of list) console.log(fmtJob(j));
104
+ if (archived.length) {
105
+ if (list.length) console.log("");
106
+ console.log("Archived (disabled by provider migration — will not fire):");
107
+ for (const j of archived) {
108
+ const label = j.label || (j.prompt ? String(j.prompt).slice(0, 50) : "(no label)");
109
+ console.log(`${j.id} ${j.kind || "job"} ${label} — ${j.archiveReason || "archived"}`);
110
+ }
111
+ }
98
112
  process.exit(0);
99
113
  } catch (e) { console.error(e.message); process.exit(1); }
100
114
  }
package/bin/tool.js CHANGED
@@ -371,7 +371,8 @@ async function run(args) {
371
371
  // calling this tool) gate on the tier the OUTERMOST invocation approved
372
372
  // instead — argv flags can't fabricate approval.
373
373
  const gate = tools.runGate(t, toolArgs, process.env);
374
- const commandLine = ["open-claudia tool run", t.name, ...toolArgs].join(" ");
374
+ const approvals = require("../core/approvals");
375
+ const commandLine = approvals.formatApprovedCommand(["open-claudia", "tool", "run", t.name, ...toolArgs]);
375
376
  let approvedVia = "";
376
377
 
377
378
  // ── Phase 3.5: per-speaker external gate ──────────────────────────────
@@ -403,6 +404,20 @@ async function run(args) {
403
404
  } else {
404
405
  const esc = await enforcer.escalate({
405
406
  speaker, kind: "action", command: commandLine, tier: gate.tier, reason: vet.reason,
407
+ canonicalUserId: process.env.OC_CANONICAL_USER_ID || "",
408
+ userId: process.env.OC_CHANNEL_USER_ID || "",
409
+ provider: process.env.OC_PROVIDER || "",
410
+ providerSettings: (() => {
411
+ try {
412
+ const value = JSON.parse(process.env.OC_PROVIDER_SETTINGS || "{}");
413
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
414
+ } catch (_) { return {}; }
415
+ })(),
416
+ project: process.env.OC_PROJECT || "",
417
+ projectDir: process.env.OC_PROJECT_DIR || "",
418
+ sessionId: process.env.OC_SESSION_ID || process.env.OC_LAST_SESSION_ID || "",
419
+ previousSessionId: process.env.OC_PREVIOUS_SESSION_ID || "",
420
+ originRunId: process.env.OC_RUN_ID || process.env.OC_ORIGIN_RUN_ID || "",
406
421
  });
407
422
  if (esc.escalated) {
408
423
  console.error(`🛡️ Held for owner approval — ${speaker.name || "the external contact"} is outside mandate for this ${gate.tier} action.`);
@@ -416,7 +431,6 @@ async function run(args) {
416
431
  }
417
432
 
418
433
  if (approvalId) {
419
- const approvals = require("../core/approvals");
420
434
  if (String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim()) {
421
435
  console.error("⛔ --approval tokens are only honored on top-level runs, not nested tool calls.");
422
436
  process.exitCode = 3; return;
@@ -436,7 +450,7 @@ async function run(args) {
436
450
  // wakeup carrying a one-shot --approval token. Anything but an explicit
437
451
  // approve = the command never runs.
438
452
  const canAsk = gate.tier === "destructive" && !gate.nested && !gate.undeclared
439
- && process.env.OC_SEND_URL && process.env.OC_SEND_TOKEN && process.env.OC_CHANNEL_ID && process.env.OC_CHANNEL_ADAPTER;
453
+ && process.env.OC_SEND_URL && process.env.OC_CHANNEL_ID && process.env.OC_CHANNEL_ADAPTER;
440
454
  if (!canAsk) { console.error(gate.message); process.exitCode = 3; return; }
441
455
  console.error(`Destructive run without --yes-destructive — asking for chat approval of the exact command...`);
442
456
  let decision = "denied";
@@ -447,7 +461,6 @@ async function run(args) {
447
461
  tool: t.name, verb: tools.invokedVerb(toolArgs), tier: gate.tier, command: commandLine,
448
462
  });
449
463
  reqId = res.id;
450
- const approvals = require("../core/approvals");
451
464
  decision = await approvals.waitForDecision(res.id);
452
465
  if (decision === "timeout") {
453
466
  approvals.detach(res.id);
@@ -464,8 +477,13 @@ async function run(args) {
464
477
  `Re-run with --yes-destructive only after the user explicitly approves.`);
465
478
  process.exitCode = 3; return;
466
479
  }
480
+ const redeemed = approvals.consume(reqId, commandLine);
481
+ if (!redeemed.ok) {
482
+ console.error(`⛔ Approval ${reqId || "(missing)"} rejected: ${redeemed.reason}. The run stays blocked.`);
483
+ process.exitCode = 3; return;
484
+ }
467
485
  approvedVia = "chat-approval";
468
- console.error(`✅ Approved in chat — running.`);
486
+ console.error(`✅ Approval ${reqId} redeemed (single use) — running.`);
469
487
  }
470
488
 
471
489
  const missing = tools.missingRequires(t);