niahere 0.3.11 → 0.4.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.
@@ -76,14 +76,24 @@ export function validateConfig(): Result {
76
76
  messages.push(`${WARN} database_url not set (will use default)`);
77
77
  }
78
78
 
79
- // Runner
79
+ // Backends (primary runner + fallback chain)
80
+ const BACKENDS = ["claude", "codex", "gemini"];
80
81
  const runner = raw.runner as string | undefined;
81
- if (runner && runner !== "claude" && runner !== "codex") {
82
- messages.push(`${FAIL} runner must be "claude" or "codex", got "${runner}"`);
82
+ if (runner && !BACKENDS.includes(runner)) {
83
+ messages.push(`${FAIL} runner must be one of ${BACKENDS.join(", ")}, got "${runner}"`);
83
84
  ok = false;
84
85
  } else if (runner) {
85
86
  messages.push(`${PASS} runner: ${runner}`);
86
87
  }
88
+ if (raw.fallback !== undefined) {
89
+ const fb = raw.fallback;
90
+ if (!Array.isArray(fb) || fb.some((b) => !BACKENDS.includes(b as string))) {
91
+ messages.push(`${FAIL} fallback must be an array of ${BACKENDS.join(", ")}`);
92
+ ok = false;
93
+ } else {
94
+ messages.push(`${PASS} fallback: [${fb.join(", ")}]`);
95
+ }
96
+ }
87
97
 
88
98
  // Session finalization
89
99
  const sf = raw.session_finalization as Record<string, unknown> | undefined;
@@ -14,6 +14,8 @@ import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler
14
14
  import { startAlive, stopAlive } from "./alive";
15
15
  import { createNiaMcpServer } from "../mcp/server";
16
16
  import { setMcpFactory } from "../mcp";
17
+ import { startMcpEndpoint, stopMcpEndpoint } from "../agent/mcp-endpoint";
18
+ import { NIA_TOOLS } from "../mcp/tools/table";
17
19
  import { processPending, cleanupOldRequests } from "./finalizer";
18
20
  import { closeAllActiveHandles } from "./active-handles";
19
21
  import { clearForceShutdownRequest, consumeForceShutdownRequest, requestForceShutdown } from "./force-shutdown";
@@ -275,6 +277,11 @@ export async function runDaemon(): Promise<void> {
275
277
  setMcpFactory((ctx) => ({ nia: createNiaMcpServer(ctx) }));
276
278
  log.info("MCP server factory initialized");
277
279
 
280
+ // Start the loopback MCP endpoint that out-of-process CLI backends (Codex/
281
+ // Gemini) connect back to for Nia's tools. Tools are injected here (the
282
+ // composition root) so the endpoint module stays free of the handler chain.
283
+ await startMcpEndpoint(NIA_TOOLS);
284
+
278
285
  // Register and start channels
279
286
  registerAllChannels();
280
287
  let channels: Channel[] = [];
@@ -386,6 +393,7 @@ export async function runDaemon(): Promise<void> {
386
393
 
387
394
  stopAlive();
388
395
  stopScheduler();
396
+ stopMcpEndpoint();
389
397
  await stopChannels(channels);
390
398
 
391
399
  try {
@@ -1,7 +1,6 @@
1
1
  import { homedir } from "os";
2
2
  import { existsSync } from "fs";
3
3
  import { randomUUID } from "crypto";
4
- import { query } from "@anthropic-ai/claude-agent-sdk";
5
4
  import type { JobInput, JobResult } from "../types";
6
5
  import { appendAudit, readState, writeState } from "../utils/logger";
7
6
  import type { AuditEntry, JobState } from "../types";
@@ -11,14 +10,11 @@ import { buildEmployeePrompt } from "../chat/employee-prompt";
11
10
  import { getEmployee } from "./employees";
12
11
  import { scanAgents } from "./agents";
13
12
  import { buildJobPrompt } from "./job-prompt";
14
- import { truncate, formatToolUse } from "../utils/format-activity";
15
13
  import { getMcpServers, type McpSourceContext } from "../mcp";
16
14
  import { ActiveEngine } from "../db/models";
17
15
  import { log } from "../utils/log";
18
- import { isRetryableApiError, sleep } from "../utils/retry";
19
16
  import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
20
- import { getSdkSkillsSetting } from "./skills";
21
- import { getSdkHooks } from "./sdk-hooks";
17
+ import { getBackend, resolveBackends, type AgentBackend, type AgentSession, type AgentSessionContext } from "../agent";
22
18
 
23
19
  export { buildWorkingMemory } from "./job-prompt";
24
20
 
@@ -29,244 +25,122 @@ interface RunnerOutput {
29
25
  sessionId: string;
30
26
  terminalReason?: string;
31
27
  error?: string;
28
+ /** The backend reported provider-down — caller may fail over to the next backend. */
29
+ providerDown?: boolean;
32
30
  }
33
31
 
34
32
  // ---------------------------------------------------------------------------
35
- // Codex runner
33
+ // Shared backend run consumer
36
34
  // ---------------------------------------------------------------------------
37
35
 
38
- function resolveCodexPath(): string {
39
- const candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"];
40
- return candidates.find((p) => existsSync(p)) || "codex";
41
- }
42
-
43
- async function runJobWithCodex(fullPrompt: string, cwd: string, model: string): Promise<RunnerOutput> {
44
- const codexPath = resolveCodexPath();
45
- const args = [
46
- codexPath,
47
- "exec",
48
- fullPrompt,
49
- "-C",
50
- cwd,
51
- "--json",
52
- "--skip-git-repo-check",
53
- "--dangerously-bypass-approvals-and-sandbox",
54
- ];
55
- if (model && model !== "default") {
56
- args.splice(3, 0, "-m", model);
57
- }
58
-
59
- const CODEX_EXCLUDED = new Set([
60
- "ANTHROPIC_API_KEY",
61
- "OPENAI_API_KEY",
62
- "GEMINI_API_KEY",
63
- "SLACK_BOT_TOKEN",
64
- "SLACK_APP_TOKEN",
65
- "TELEGRAM_BOT_TOKEN",
66
- "TWILIO_AUTH_TOKEN",
67
- "DATABASE_URL",
68
- ]);
69
- const codexEnv = Object.fromEntries(
70
- Object.entries(process.env).filter(([k]) => !CODEX_EXCLUDED.has(k))
71
- );
72
-
73
- const proc = Bun.spawn(args, {
74
- stdout: "pipe",
75
- stderr: "pipe",
76
- env: codexEnv,
77
- });
78
-
79
- const stdout = await new Response(proc.stdout).text();
80
- const stderr = await new Response(proc.stderr).text();
81
- const exitCode = await proc.exited;
82
-
83
- let agentText = "";
84
- let sessionId = "";
85
- for (const line of stdout.split("\n")) {
86
- if (!line.trim()) continue;
87
- try {
88
- const event = JSON.parse(line);
89
- if (event.type === "thread.started" && event.thread_id) {
90
- sessionId = event.thread_id;
91
- }
92
- if (event.type === "item.completed" && event.item?.type === "agent_message") {
93
- agentText = event.item.text || "";
94
- }
95
- } catch {}
96
- }
97
-
98
- if (exitCode !== 0) {
99
- return {
100
- agentText,
101
- sessionId,
102
- error: stderr.trim() || `exit code ${exitCode}`,
103
- };
104
- }
105
- return { agentText, sessionId };
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // Claude Agent SDK runner
110
- // ---------------------------------------------------------------------------
111
-
112
- export async function runJobWithClaude(
113
- systemPrompt: string,
114
- jobPrompt: string,
115
- cwd: string,
36
+ /**
37
+ * Drive one backend session to a `RunnerOutput`: map `AgentEvent`s to activity +
38
+ * result/error, and handle abort. Shared by the Claude and Codex job paths so
39
+ * the consume logic lives in exactly one place.
40
+ */
41
+ async function consumeBackendRun(
42
+ session: AgentSession,
43
+ prompt: string,
116
44
  onActivity?: ActivityCallback,
117
- model?: string,
118
- sourceCtx?: McpSourceContext,
119
45
  activeRoom?: string,
120
46
  ): Promise<RunnerOutput> {
121
- const sessionId = randomUUID();
122
-
123
- // One-shot async iterable: emit a single user message then close
124
- async function* singleMessage() {
125
- yield {
126
- type: "user" as const,
127
- message: { role: "user" as const, content: jobPrompt },
128
- parent_tool_use_id: null,
129
- session_id: "",
130
- };
131
- }
132
-
133
- const options: Record<string, unknown> = {
134
- systemPrompt,
135
- cwd,
136
- permissionMode: "bypassPermissions",
137
- sessionId,
138
- skills: getSdkSkillsSetting(),
139
- hooks: getSdkHooks(),
140
- };
141
-
142
- if (model && model !== "default") {
143
- options.model = model;
144
- }
145
-
146
- const mcpServers = getMcpServers(sourceCtx);
147
- if (mcpServers) {
148
- options.mcpServers = mcpServers;
149
- }
150
-
151
- const handle = query({
152
- prompt: singleMessage() as any,
153
- options: options as any,
154
- });
155
47
  let abortReason: string | null = null;
156
48
  if (activeRoom) {
157
49
  registerActiveHandle(activeRoom, (reason) => {
158
50
  abortReason = reason;
159
- handle.close();
51
+ session.abort(reason);
160
52
  });
161
53
  }
162
54
 
163
55
  let agentText = "";
164
- let actualSessionId = sessionId;
165
56
  let terminalReason: string | undefined;
166
- let accumulatedThinking = "";
167
- let lastThinkingLine = "";
57
+ let error: string | undefined;
58
+ let providerDown = false;
168
59
 
169
60
  try {
170
- for await (const message of handle) {
171
- if (message.type === "system" && (message as any).subtype === "init") {
172
- actualSessionId = (message as any).session_id || sessionId;
173
- }
174
-
175
- // Stream activity events
176
- if (onActivity) {
177
- const msg = message as any;
178
-
179
- if (message.type === "stream_event") {
180
- const event = msg.event;
181
- if (event?.type === "content_block_start" && event.content_block?.type === "thinking") {
182
- accumulatedThinking = "";
183
- lastThinkingLine = "";
184
- onActivity("thinking...");
185
- }
186
- if (event?.type === "content_block_delta") {
187
- const delta = event.delta;
188
- if (delta?.type === "thinking_delta" && delta.thinking) {
189
- accumulatedThinking += delta.thinking;
190
- const lines = accumulatedThinking.split("\n");
191
- if (lines.length > 1) {
192
- const completeLine = lines[lines.length - 2]?.trim();
193
- if (completeLine && completeLine !== lastThinkingLine) {
194
- lastThinkingLine = completeLine;
195
- onActivity(truncate(completeLine, 70));
196
- }
197
- }
198
- }
199
- }
200
- if (event?.type === "content_block_stop") {
201
- accumulatedThinking = "";
202
- lastThinkingLine = "";
203
- }
204
- }
205
-
206
- if (message.type === "tool_use_summary") {
207
- const name = msg.tool_name || "tool";
208
- onActivity(formatToolUse(name, msg.tool_input));
209
- }
210
-
211
- if (message.type === "tool_progress") {
212
- if (msg.tool_name === "Bash" && msg.content) {
213
- onActivity(`$ ${truncate(msg.content, 60)}`);
214
- } else if (msg.content) {
215
- onActivity(truncate(msg.content, 70));
216
- }
217
- }
218
-
219
- if (message.type === "system") {
220
- if (msg.subtype === "task_started" && msg.description) {
221
- onActivity(truncate(msg.description, 60));
222
- }
223
- if (msg.subtype === "task_progress" && msg.last_tool_name) {
224
- onActivity(msg.summary || msg.last_tool_name);
225
- }
226
- }
227
- }
228
-
229
- if (message.type === "result") {
230
- if (!(message as any).is_error) {
231
- agentText = (message as any).result || "";
232
- terminalReason = (message as any).terminal_reason;
233
- } else {
234
- const errors = (message as any).errors;
235
- terminalReason = (message as any).terminal_reason;
236
- return {
237
- agentText: "",
238
- sessionId: actualSessionId,
239
- terminalReason,
240
- error: errors?.join(", ") || "unknown error",
241
- };
242
- }
61
+ for await (const ev of session.send(prompt)) {
62
+ if (ev.type === "thinking") onActivity?.(ev.delta);
63
+ else if (ev.type === "tool") onActivity?.(ev.summary ?? ev.name);
64
+ else if (ev.type === "result") {
65
+ agentText = ev.text;
66
+ terminalReason = ev.terminalReason;
67
+ } else if (ev.type === "error") {
68
+ error = ev.message;
69
+ terminalReason = ev.terminalReason;
70
+ providerDown = ev.providerDown;
243
71
  }
244
72
  }
245
73
  } catch (err) {
246
74
  if (abortReason) {
247
75
  return {
248
76
  agentText: "",
249
- sessionId: actualSessionId,
77
+ sessionId: session.backendSessionId ?? "",
250
78
  terminalReason: "aborted",
251
79
  error: abortReason,
252
80
  };
253
81
  }
254
82
  throw err;
255
83
  } finally {
256
- handle.close();
84
+ await session.close();
257
85
  if (activeRoom) unregisterActiveHandle(activeRoom);
258
86
  }
259
87
 
260
88
  if (abortReason) {
261
- return {
262
- agentText: "",
263
- sessionId: actualSessionId,
264
- terminalReason: "aborted",
265
- error: abortReason,
266
- };
89
+ return { agentText: "", sessionId: session.backendSessionId ?? "", terminalReason: "aborted", error: abortReason };
90
+ }
91
+
92
+ return { agentText, sessionId: session.backendSessionId ?? "", terminalReason, error, providerDown };
93
+ }
94
+
95
+ /**
96
+ * Run a job across the ordered backend chain: try the primary, and on a
97
+ * provider-down result fail over to the next backend (replaying the same prompt;
98
+ * continuity comes from Nia's own context, not a cross-backend session resume).
99
+ */
100
+ export async function runJobAcrossBackends(
101
+ backends: AgentBackend[],
102
+ sessionCtx: AgentSessionContext,
103
+ jobPrompt: string,
104
+ onActivity?: ActivityCallback,
105
+ activeRoom?: string,
106
+ ): Promise<RunnerOutput> {
107
+ let output: RunnerOutput = { agentText: "", sessionId: "", error: "no backend configured" };
108
+ for (let i = 0; i < backends.length; i++) {
109
+ const backend = backends[i]!;
110
+ const session = await backend.openSession(sessionCtx);
111
+ output = await consumeBackendRun(session, jobPrompt, onActivity, activeRoom);
112
+ if (!output.providerDown) return output;
113
+ const next = backends[i + 1];
114
+ if (next) log.warn({ from: backend.name, to: next.name }, "provider down, failing over to next backend");
267
115
  }
116
+ return output;
117
+ }
268
118
 
269
- return { agentText, sessionId: actualSessionId, terminalReason };
119
+ /**
120
+ * Run a one-shot job on the in-process Claude backend. Kept as a named export
121
+ * (signature stable) because `alive.ts` and `runTask` call it directly.
122
+ */
123
+ export async function runJobWithClaude(
124
+ systemPrompt: string,
125
+ jobPrompt: string,
126
+ cwd: string,
127
+ onActivity?: ActivityCallback,
128
+ model?: string,
129
+ sourceCtx?: McpSourceContext,
130
+ activeRoom?: string,
131
+ ): Promise<RunnerOutput> {
132
+ const mcpServers = (getMcpServers(sourceCtx) as Record<string, unknown> | undefined) ?? undefined;
133
+ const session = await getBackend().openSession({
134
+ room: activeRoom ?? `_oneshot/${randomUUID()}`,
135
+ channel: "system",
136
+ systemPrompt,
137
+ cwd,
138
+ model,
139
+ mcpServers,
140
+ source: sourceCtx,
141
+ resume: false,
142
+ });
143
+ return consumeBackendRun(session, jobPrompt, onActivity, activeRoom);
270
144
  }
271
145
 
272
146
  // ---------------------------------------------------------------------------
@@ -354,27 +228,22 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
354
228
  // Model priority: job.model > agent.model > config.model
355
229
  const resolvedModel = job.model || agentModel || config.model;
356
230
 
357
- const MAX_API_RETRIES = 2;
358
- const RETRY_DELAYS = [3_000, 8_000]; // 3s, then 8s
359
-
360
231
  const jobSourceCtx: McpSourceContext = { jobName: job.name, channel: "system" };
361
232
 
362
- if (config.runner === "codex") {
363
- const fullPrompt = `${systemPrompt}\n\n---\n\n${jobPrompt}`;
364
- output = await runJobWithCodex(fullPrompt, cwd, resolvedModel);
365
- } else {
366
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx, room);
367
-
368
- for (let attempt = 0; attempt < MAX_API_RETRIES && output.error && isRetryableApiError(output.error); attempt++) {
369
- const delay = RETRY_DELAYS[attempt] ?? 8_000;
370
- log.warn(
371
- { job: job.name, attempt: attempt + 1, error: output.error, delayMs: delay },
372
- "retrying after transient API error",
373
- );
374
- await sleep(delay);
375
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx, room);
376
- }
377
- }
233
+ // One context serves every backend: Claude uses the pre-built in-process
234
+ // mcpServers; Codex/Gemini use `source` to wire the loopback endpoint. Run
235
+ // across the configured backend chain so a provider-down primary fails over.
236
+ const sessionCtx: AgentSessionContext = {
237
+ room,
238
+ channel: "system",
239
+ systemPrompt,
240
+ cwd,
241
+ model: resolvedModel,
242
+ mcpServers: (getMcpServers(jobSourceCtx) as Record<string, unknown> | undefined) ?? undefined,
243
+ source: jobSourceCtx,
244
+ resume: false,
245
+ };
246
+ output = await runJobAcrossBackends(resolveBackends(), sessionCtx, jobPrompt, onActivity, room);
378
247
 
379
248
  const duration_ms = Math.round(performance.now() - startMs);
380
249
  const ok = !output.error;