@noesis-brain/mcp-server 2.1.3 → 2.3.1

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 (36) hide show
  1. package/dist/agent/identity.d.ts +33 -0
  2. package/dist/agent/identity.d.ts.map +1 -0
  3. package/dist/agent/identity.js +61 -0
  4. package/dist/agent/identity.js.map +1 -0
  5. package/dist/agent/runner.d.ts +194 -0
  6. package/dist/agent/runner.d.ts.map +1 -1
  7. package/dist/agent/runner.js +465 -50
  8. package/dist/agent/runner.js.map +1 -1
  9. package/dist/agent/tokenState.d.ts +81 -0
  10. package/dist/agent/tokenState.d.ts.map +1 -0
  11. package/dist/agent/tokenState.js +173 -0
  12. package/dist/agent/tokenState.js.map +1 -0
  13. package/dist/agent/webGuard.d.ts +48 -0
  14. package/dist/agent/webGuard.d.ts.map +1 -0
  15. package/dist/agent/webGuard.js +173 -0
  16. package/dist/agent/webGuard.js.map +1 -0
  17. package/dist/api/NoesisClient.d.ts +13 -1
  18. package/dist/api/NoesisClient.d.ts.map +1 -1
  19. package/dist/api/NoesisClient.js +18 -3
  20. package/dist/api/NoesisClient.js.map +1 -1
  21. package/dist/tools/claudeSessions.d.ts +72 -0
  22. package/dist/tools/claudeSessions.d.ts.map +1 -0
  23. package/dist/tools/claudeSessions.js +304 -0
  24. package/dist/tools/claudeSessions.js.map +1 -0
  25. package/dist/tools/index.d.ts +1 -6
  26. package/dist/tools/index.d.ts.map +1 -1
  27. package/dist/tools/index.js +188 -55
  28. package/dist/tools/index.js.map +1 -1
  29. package/dist/tools/sessionIndex.d.ts +66 -0
  30. package/dist/tools/sessionIndex.d.ts.map +1 -0
  31. package/dist/tools/sessionIndex.js +241 -0
  32. package/dist/tools/sessionIndex.js.map +1 -0
  33. package/package.json +1 -1
  34. package/scripts/noesis-capture-watcher.mjs +722 -107
  35. package/scripts/start-agent.sh +331 -0
  36. package/skill-templates/noesis-refine-relations.md +163 -0
@@ -15,10 +15,25 @@
15
15
  */
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { checkWebFetchUrl, webFetchEnvWarnings } from './webGuard.js';
19
+ import { resolveIdentity } from './identity.js';
20
+ import { TokenGate, applyStartupCheck, checkToken, classifyRefusal } from './tokenState.js';
18
21
  const POLL_INTERVAL_MS = 2000; // idle poll cadence
19
22
  const CHUNK_FLUSH_MS = 500; // batch streamed deltas before POST /events
20
23
  const KEEPALIVE_MS = 30_000; // hold the lease during quiet stretches (< server's 120s stale)
21
24
  const MAX_CONCURRENCY = 2;
25
+ function parseFakeMode(raw) {
26
+ const v = (raw || '').trim().toLowerCase();
27
+ if (v === '1' || v === 'canned')
28
+ return 'canned';
29
+ if (v === 'rate_limit' || v === 'auth')
30
+ return v;
31
+ return undefined;
32
+ }
33
+ function nonNegative(raw, fallback) {
34
+ const n = Number(raw);
35
+ return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 ? n : fallback;
36
+ }
22
37
  export function resolveAgentConfig() {
23
38
  const apiBaseUrl = (process.env.NOESIS_API_URL || '').replace(/\/$/, '');
24
39
  const apiToken = process.env.NOESIS_API_TOKEN || '';
@@ -30,7 +45,16 @@ export function resolveAgentConfig() {
30
45
  concurrency = 1;
31
46
  if (concurrency > MAX_CONCURRENCY)
32
47
  concurrency = MAX_CONCURRENCY;
33
- return { apiBaseUrl, apiToken, concurrency, fake: process.env.NOESIS_AGENT_FAKE === '1' };
48
+ const fakeMode = parseFakeMode(process.env.NOESIS_AGENT_FAKE);
49
+ return {
50
+ apiBaseUrl,
51
+ apiToken,
52
+ concurrency,
53
+ fake: fakeMode !== undefined,
54
+ fakeMode,
55
+ fakeResetAt: Date.now() + nonNegative(process.env.NOESIS_AGENT_FAKE_RESET_MIN, 3) * 60_000,
56
+ fakeWordMs: nonNegative(process.env.NOESIS_AGENT_FAKE_WORD_MS, 15),
57
+ };
34
58
  }
35
59
  /**
36
60
  * The ONLY tools the server may pre-approve — a literal allowlist, not a namespace
@@ -47,6 +71,16 @@ const ALLOWED_TOOLS = new Set([
47
71
  'mcp__noesis__get_note',
48
72
  'mcp__noesis__list_notes',
49
73
  ]);
74
+ /**
75
+ * Claude Code built-ins a server may request: network-only, no filesystem access. This is
76
+ * the ONLY route by which the SDK's `tools` option is ever non-empty (see buildQueryOptions).
77
+ *
78
+ * A separate list from ALLOWED_TOOLS on purpose, and never pre-approved: every call goes
79
+ * through buildCanUseTool, where a WebFetch URL must pass webGuard, because this daemon
80
+ * runs on the user's own machine and "fetch http://192.168.1.1" would reach their LAN.
81
+ * The backend asks for these only for a Navi whose Web Search toggle is on.
82
+ */
83
+ const ALLOWED_WEB_TOOLS = ['WebFetch', 'WebSearch'];
50
84
  /** Highest turn budget a server may request; a tool loop otherwise burns the subscription. */
51
85
  const MAX_TURNS_CEILING = 12;
52
86
  /** Drop anything the server asked for that is not on the read-only allowlist. */
@@ -59,6 +93,12 @@ export function clampAllowedTools(raw) {
59
93
  console.error(`[noesis-agent] dropped ${dropped} tool name(s) not on the read-only allowlist`);
60
94
  return kept;
61
95
  }
96
+ /** The web built-ins this job asked for, in a fixed order. Nothing else can come out. */
97
+ export function clampWebTools(raw) {
98
+ if (!Array.isArray(raw))
99
+ return [];
100
+ return ALLOWED_WEB_TOOLS.filter((t) => raw.includes(t));
101
+ }
62
102
  /** Clamp the server-requested turn budget into a sane range. */
63
103
  export function clampMaxTurns(raw) {
64
104
  const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 1;
@@ -102,13 +142,58 @@ export function agentVersion() {
102
142
  return '0.0.0';
103
143
  }
104
144
  }
105
- async function claim(cfg, workerId) {
106
- const res = await api(cfg, '/api/agent/jobs/claim', { workerId, agentVersion: agentVersion() });
145
+ let identity = null;
146
+ /** This process's machine identity, resolved once (daemonId is stable; bootId is per process). */
147
+ function daemonIdentity() {
148
+ identity ??= resolveIdentity();
149
+ return identity;
150
+ }
151
+ /**
152
+ * What every claim sends. The server keeps one health record per machine (daemonId), tells
153
+ * a restart from a second process by bootId, and shows label/platform in Settings. Older
154
+ * servers read only workerId + agentVersion and ignore the rest.
155
+ */
156
+ export function claimBody(workerId, id) {
157
+ return { workerId, agentVersion: agentVersion(), daemonId: id.daemonId, bootId: id.bootId, label: id.label, platform: id.platform };
158
+ }
159
+ /** The token state every worker shares (see tokenState.ts). */
160
+ const tokenGate = new TokenGate();
161
+ /**
162
+ * Read a claim answer. A 401 means the server refused this daemon's API token (expired,
163
+ * revoked or unknown): the shared gate explains it once and the workers back off, instead
164
+ * of retrying silently every 2 s. Any accepted answer clears that state and feeds the
165
+ * token-expiry warning.
166
+ */
167
+ export async function handleClaimResponse(res, gate) {
168
+ if (res.status === 401) {
169
+ gate.refused(classifyRefusal(await res.json().catch(() => ({}))));
170
+ return null;
171
+ }
107
172
  if (!res.ok)
108
173
  return null;
174
+ gate.accepted();
175
+ gate.expiryHeader(res.headers.get('x-noesis-token-expires'));
109
176
  const data = (await res.json());
110
177
  return data.job ?? null;
111
178
  }
179
+ async function claim(cfg, workerId) {
180
+ const res = await api(cfg, '/api/agent/jobs/claim', claimBody(workerId, daemonIdentity()));
181
+ return handleClaimResponse(res, tokenGate);
182
+ }
183
+ /** Sleep, but wake at once on shutdown — the refused-token backoff is a minute long. */
184
+ function sleep(ms, signal) {
185
+ return new Promise((resolve) => {
186
+ if (signal?.aborted)
187
+ return resolve();
188
+ const timer = setTimeout(done, ms);
189
+ function done() {
190
+ clearTimeout(timer);
191
+ signal?.removeEventListener('abort', done);
192
+ resolve();
193
+ }
194
+ signal?.addEventListener('abort', done, { once: true });
195
+ });
196
+ }
112
197
  /** Buffers streamed text and flushes it as lease-fenced chunk events + keepalives. */
113
198
  class EventSink {
114
199
  cfg;
@@ -208,23 +293,164 @@ export function buildSdkPrompt(prompt, images) {
208
293
  return singleUserMessage(buildImageContent(prompt, images));
209
294
  return prompt;
210
295
  }
296
+ /**
297
+ * SECOND, INDEPENDENT LAYER on the tool boundary.
298
+ *
299
+ * The `tools` option below (`[]`, or at most the two web tools) is the primary control, but
300
+ * it works by way of the CLI's `--tools` flag, whose handling lives in a minified `cli.js`
301
+ * we cannot audit. This callback is a
302
+ * gate we own outright: the SDK routes every permission request through it
303
+ * (`--permission-prompt-tool stdio`), and the request carries an `agentID`, so calls made
304
+ * by a spawned `Task` SUBAGENT are covered too — which matters, because a subagent, not
305
+ * the main loop, did the file reading in the 2026-08-14 incident.
306
+ *
307
+ * Deliberately fed from ALLOWED_TOOLS / ALLOWED_WEB_TOOLS (via the per-job clamps) so the
308
+ * daemon has ONE definition of what may run.
309
+ *
310
+ * DO NOT DELETE THIS AS DEAD CODE. It is the ONLY daemon-owned control over MCP tools.
311
+ * `tools: []` governs the SDK's BUILT-IN set and nothing else; the Noesis MCP server we
312
+ * spawn registers ~55 tools, and their definitions are not filtered by `allowedTools`, so
313
+ * the model can see and attempt every one of them — including `pull_notes`, which writes
314
+ * to an arbitrary local path, plus `add_root`, `sync_notes`, `trash_note`, `move_note`.
315
+ * For those, this callback is the whole boundary.
316
+ *
317
+ * MEASURED (30 runs, 2026-08-15), correcting an earlier comment that said the opposite:
318
+ * the gate DOES fire — it denied `mcp__noesis__list_roots` and `mcp__noesis__list_codebases`
319
+ * in real traffic, and the model received `is_error` results naming the refusal. An earlier
320
+ * 2-job sample saw zero firings and wrongly concluded the gate was inert; a permission tool
321
+ * is simply not consulted for calls that are already pre-approved, and that sample happened
322
+ * to exercise only pre-approved tools.
323
+ *
324
+ * KNOWN GAP, stated rather than glossed: the SDK's cache-warming sidechain
325
+ * (`isSidechain: true`) is a real model turn that emits genuine `Bash`/`Glob` calls against
326
+ * the daemon's cwd, with only the RESULT stubbed. `tools: []` does not reach it and this
327
+ * callback is never consulted there. Nothing executes — but that is Claude Code internal
328
+ * behaviour we neither own nor test, which is why the daemon is rooted at a neutral cwd
329
+ * (`scripts/start-agent.sh`), so the warmup probes an empty directory, not a source tree.
330
+ *
331
+ * WEB TOOLS: a permitted WebFetch is still refused unless its URL passes webGuard (public
332
+ * http(s) host only). The job's permitted set is checked FIRST, so a job never cleared
333
+ * for web cannot fetch even a public page. WebSearch needs no address check: the search
334
+ * runs on Anthropic's side, not from this machine. One exception the gate cannot see: the
335
+ * CLI itself pre-approves ~85 public documentation hosts for WebFetch and never asks. Those
336
+ * are public by definition, and executeJob logs every tool call from the stream anyway.
337
+ */
338
+ export function buildCanUseTool(
339
+ /**
340
+ * The tools permitted for THIS job — i.e. the output of `clampAllowedTools`, not the
341
+ * module-wide `ALLOWED_TOOLS`. Required, deliberately: an earlier version closed over
342
+ * `ALLOWED_TOOLS` and so turned a per-job clamp into a union. `clampAllowedTools`
343
+ * narrows to (what the server asked for ∩ ALLOWED_TOOLS); the gate then re-allowed all
344
+ * four regardless, so a job clamped to `get_note` could still execute `search_semantic`.
345
+ * The sets happen to coincide for chat today, which is exactly why it would have gone
346
+ * unnoticed until the first job kind with a narrower allowlist.
347
+ */
348
+ permitted, onDecision = logToolDecision, checkUrl = checkWebFetchUrl) {
349
+ const permittedSet = new Set(permitted);
350
+ return async (toolName, input, ctx) => {
351
+ if (!permittedSet.has(toolName)) {
352
+ onDecision(toolName, false, ctx?.agentID);
353
+ return { behavior: 'deny', message: `Tool "${toolName}" is not permitted for a Noesis job.` };
354
+ }
355
+ const args = (input ?? {});
356
+ if (toolName === 'WebFetch') {
357
+ const verdict = await checkUrl(args.url);
358
+ if (!verdict.ok) {
359
+ onDecision(toolName, false, ctx?.agentID, `${String(args.url)} (${verdict.reason})`);
360
+ return {
361
+ behavior: 'deny',
362
+ message: `WebFetch refused: ${verdict.reason}. Only public internet addresses can be fetched.`,
363
+ };
364
+ }
365
+ }
366
+ onDecision(toolName, true, ctx?.agentID, toolCallDetail(toolName, args));
367
+ return { behavior: 'allow', updatedInput: args };
368
+ };
369
+ }
370
+ /** The URL or query a web tool call is about, for the log; undefined for other tools. */
371
+ function toolCallDetail(toolName, args) {
372
+ const value = toolName === 'WebFetch' ? args.url : toolName === 'WebSearch' ? args.query : undefined;
373
+ return typeof value === 'string' ? value.slice(0, 300) : undefined;
374
+ }
375
+ /** Default sink: stderr, so the daemon log is the evidence for whether the gate ever fires. */
376
+ function logToolDecision(toolName, allowed, agentID, detail) {
377
+ console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${detail ? ` ${detail}` : ''}${agentID ? ` (agent=${agentID})` : ''}`);
378
+ }
379
+ /**
380
+ * The `options` object handed to sdk.query() — extracted, like buildSdkPrompt above, so
381
+ * the tool boundary is asserted by a test instead of resting on a comment. `tools` is the
382
+ * security-relevant field: see the note at its assignment. Takes the MCP-server
383
+ * factory as an argument so a test can supply a stub instead of a real spawn config.
384
+ */
385
+ export function buildQueryOptions(payload, mcpServersFor) {
386
+ const requested = Array.isArray(payload.allowedTools) ? payload.allowedTools : [];
387
+ const webTools = clampWebTools(requested);
388
+ // Web names have their own clamp; keeping them out of this one stops it logging them as
389
+ // "dropped" on every web job. What it keeps is unchanged: the four read-only lookups.
390
+ const allowedTools = clampAllowedTools(requested.filter((t) => !webTools.includes(t)));
391
+ return {
392
+ ...(payload.system ? { systemPrompt: payload.system } : {}),
393
+ // Suppress the SDK's built-in tool set (`--tools <list>`) down to at most the two web
394
+ // tools. Without the option the CLI defaults to ALL built-ins — Bash, Read, Write,
395
+ // Edit, Grep, WebSearch — and ALLOWED_TOOLS above does NOT stop them: `allowedTools`
396
+ // is a PERMISSION allowlist, so unlisted tools are un-pre-approved, not absent, and
397
+ // their definitions still reach the model. Worse, the read-only built-ins need no
398
+ // approval at all, so they actually EXECUTE headless: on 2026-08-14 an English-coach
399
+ // Navi grepped this daemon's cwd and returned ~100 strings from the user's source tree
400
+ // (jobs 527/528); only the follow-up file Write stopped, at the permission gate.
401
+ // So this is `[]` for every job that did not ask for web, and never more than
402
+ // ALLOWED_WEB_TOOLS — network-only, no filesystem access. A server-supplied payload
403
+ // still can never ask for a filesystem primitive. MCP tools travel a separate channel
404
+ // (`--mcp-config`) and are unaffected: a `use_knowledge_base` Navi still calls
405
+ // search_notes (verified).
406
+ tools: webTools,
407
+ // Independent second layer — see buildCanUseTool. Covers subagent (`Task`) calls too.
408
+ // Web tools are deliberately left out of `allowedTools` below (never pre-approved),
409
+ // so every WebFetch reaches this gate and its URL check.
410
+ canUseTool: buildCanUseTool([...allowedTools, ...webTools]),
411
+ allowedTools,
412
+ // Only spawn the Noesis MCP server when a tool actually survived clamping.
413
+ ...(allowedTools.length > 0 ? { mcpServers: mcpServersFor() } : {}),
414
+ maxTurns: clampMaxTurns(payload.maxTurns),
415
+ includePartialMessages: true,
416
+ };
417
+ }
418
+ /** The prompt text for a job, with the fallback used when a payload carries none. */
419
+ export function resolveJobPrompt(payload, kindLabel) {
420
+ return payload.prompt ?? `(${kindLabel} job with empty prompt)`;
421
+ }
422
+ /**
423
+ * The ONE place the SDK is invoked. Exported so a test can pass a fake `sdk` and assert
424
+ * what actually reaches `query()`.
425
+ *
426
+ * This exists because of a real gap: the suite pinned `buildQueryOptions` with a
427
+ * power-set mutation battery, but NOTHING pinned the call. Restoring the pre-fix inline
428
+ * options literal here — deleting `tools: []` and `canUseTool` outright — reverted the
429
+ * entire tool boundary with all 79 tests still green. The builder being correct is
430
+ * worthless if the caller stops using it, so both the seam below and the source-fitness
431
+ * test in `agentToolClamp.test.ts` guard this.
432
+ */
433
+ export function runSdkQuery(sdk, payload, kindLabel, mcpServersFor) {
434
+ return sdk.query({
435
+ prompt: buildSdkPrompt(resolveJobPrompt(payload, kindLabel), payload.images),
436
+ options: buildQueryOptions(payload, mcpServersFor),
437
+ });
438
+ }
211
439
  /**
212
440
  * Execute a claimed job and stream its output through the sink. Returns the final
213
441
  * text. Real mode runs the Claude Agent SDK on the user's subscription; fake mode
214
442
  * emits a canned response so the queue plumbing can be verified without a login.
215
443
  */
216
444
  async function executeJob(cfg, job, sink) {
217
- const prompt = job.payload.prompt ?? `(${job.kind} job with empty prompt)`;
218
- const images = job.payload.images;
445
+ const prompt = resolveJobPrompt(job.payload, job.kind);
219
446
  if (cfg.fake) {
220
- const canned = `Noesis local agent (fake mode) processed a "${job.kind}" job. Prompt was: ${prompt.slice(0, 80)}`;
221
- for (const word of canned.split(' ')) {
222
- if (sink.isClosed())
223
- break;
224
- sink.push(word + ' ');
225
- await new Promise((r) => setTimeout(r, 15));
226
- }
227
- return canned;
447
+ const fake = fakeSdk(cfg.fakeMode ?? 'canned', {
448
+ kind: job.kind,
449
+ promptText: prompt,
450
+ resetAt: cfg.fakeResetAt ?? 0,
451
+ wordMs: cfg.fakeWordMs ?? 15,
452
+ });
453
+ return consumeAgentStream(runSdkQuery(fake, job.payload, job.kind, () => ({})), sink);
228
454
  }
229
455
  // Real path: the Agent SDK uses the ambient Claude Code subscription login.
230
456
  // Dynamically imported via a NON-LITERAL specifier so tsc doesn't require the
@@ -235,47 +461,170 @@ async function executeJob(cfg, job, sink) {
235
461
  const sdk = await import(sdkModule).catch(() => {
236
462
  throw new Error('Install @anthropic-ai/claude-agent-sdk to run the Noesis local agent (real mode).');
237
463
  });
464
+ const stream = runSdkQuery(sdk, job.payload, job.kind, () => noesisMcpServers(cfg));
465
+ return consumeAgentStream(stream, sink);
466
+ }
467
+ /**
468
+ * Appended when the SDK stops a run at maxTurns. Without it the saved reply would be only
469
+ * the narration that came before the tool calls ("let me check the official page…"), with
470
+ * nothing saying the answer was cut off.
471
+ */
472
+ export const TURN_LIMIT_NOTE = '\n\n_(Stopped at the step limit before finishing. Ask me to continue.)_';
473
+ /**
474
+ * Turn one SDK message stream into the reply text, pushing it to `out` as it arrives.
475
+ * Extracted from executeJob so these rules are testable against a fake stream:
476
+ * - With includePartialMessages the stream carries BOTH incremental `stream_event` deltas
477
+ * AND a terminal `assistant` message holding the whole reply. Taking both would emit the
478
+ * answer twice, so deltas win and the terminal message is only a fallback for a stream
479
+ * that produced none (older SDK, or partials unsupported).
480
+ * - A multi-turn run (every web fetch or note lookup) gets a paragraph break between turns,
481
+ * so narration does not run into the answer. Only when the next turn actually has text,
482
+ * so a tool-only turn adds nothing.
483
+ * - Every tool call is logged, including WebFetch calls to the hosts the CLI pre-approves
484
+ * itself, which never reach canUseTool.
485
+ * - Hitting maxTurns appends TURN_LIMIT_NOTE.
486
+ */
487
+ export async function consumeAgentStream(stream, out, logToolCall = logStreamToolCall) {
238
488
  let finalText = '';
239
489
  let sawDelta = false;
240
490
  let terminalText = '';
241
- const allowedTools = clampAllowedTools(job.payload.allowedTools);
242
- const stream = sdk.query({
243
- prompt: buildSdkPrompt(prompt, images),
244
- options: {
245
- ...(job.payload.system ? { systemPrompt: job.payload.system } : {}),
246
- allowedTools,
247
- // Only spawn the Noesis MCP server when a tool actually survived clamping.
248
- ...(allowedTools.length > 0 ? { mcpServers: noesisMcpServers(cfg) } : {}),
249
- maxTurns: clampMaxTurns(job.payload.maxTurns),
250
- includePartialMessages: true,
251
- },
252
- });
253
- // With includePartialMessages the stream carries BOTH incremental `stream_event`
254
- // deltas AND a terminal `assistant` message holding the whole reply. Taking both
255
- // would emit the answer twice, so deltas win and the terminal message is only a
256
- // fallback for a stream that produced none (older SDK, or partials unsupported).
257
- for await (const message of stream) {
258
- if (sink.isClosed())
259
- break;
260
- const delta = extractStreamDelta(message);
261
- if (delta) {
262
- sawDelta = true;
263
- sink.push(delta);
264
- finalText += delta;
265
- continue;
491
+ let breakPending = false;
492
+ let hitTurnLimit = false;
493
+ // An API failure (usage limit, revoked login, …) arrives as an assistant message
494
+ // carrying `error`, then a result with is_error, then the CLI exits 1 and the SDK
495
+ // throws a bare "Claude Code process exited with code 1". Keep the failure's own text
496
+ // and code — the server routes on them — and never let it become the reply.
497
+ let apiError = null;
498
+ let failedResultText = null;
499
+ let succeeded = false;
500
+ const emit = (text) => {
501
+ out.push(text);
502
+ finalText += text;
503
+ };
504
+ const failure = () => {
505
+ if (apiError)
506
+ return new AgentRunError(apiError.text, apiError.code, apiError.resetsAt);
507
+ if (failedResultText !== null)
508
+ return new AgentRunError(failedResultText || 'Claude run failed', 'unknown');
509
+ return null;
510
+ };
511
+ try {
512
+ for await (const message of stream) {
513
+ if (out.isClosed())
514
+ break;
515
+ const err = extractApiError(message);
516
+ if (err) {
517
+ apiError = err;
518
+ continue;
519
+ }
520
+ const outcome = resultOutcome(message);
521
+ if (outcome?.isError)
522
+ failedResultText = outcome.text;
523
+ else if (outcome)
524
+ succeeded = true;
525
+ if (isMessageStart(message)) {
526
+ if (finalText)
527
+ breakPending = true;
528
+ continue;
529
+ }
530
+ const delta = extractStreamDelta(message);
531
+ if (delta) {
532
+ sawDelta = true;
533
+ if (breakPending) {
534
+ breakPending = false;
535
+ const gap = paragraphGap(finalText);
536
+ if (gap)
537
+ emit(gap);
538
+ }
539
+ emit(delta);
540
+ continue;
541
+ }
542
+ for (const call of extractToolUses(message))
543
+ logToolCall(call.name, call.detail);
544
+ if (isTurnLimitResult(message))
545
+ hitTurnLimit = true;
546
+ // Concatenate, don't overwrite: with tools the run is multi-turn, and a
547
+ // stream that produced no deltas would otherwise keep only the LAST turn.
548
+ const whole = extractTerminalText(message);
549
+ if (whole)
550
+ terminalText += (terminalText ? paragraphGap(terminalText) : '') + whole;
266
551
  }
267
- // Concatenate, don't overwrite: with tools the run is multi-turn, and a
268
- // stream that produced no deltas would otherwise keep only the LAST turn.
269
- const whole = extractTerminalText(message);
270
- if (whole)
271
- terminalText += whole;
552
+ }
553
+ catch (e) {
554
+ throw failure() ?? e;
555
+ }
556
+ if (!succeeded) {
557
+ const f = failure();
558
+ if (f)
559
+ throw f;
272
560
  }
273
561
  if (!sawDelta && terminalText) {
274
- sink.push(terminalText);
562
+ out.push(terminalText);
275
563
  finalText = terminalText;
276
564
  }
565
+ if (hitTurnLimit && !out.isClosed())
566
+ emit(finalText ? TURN_LIMIT_NOTE : TURN_LIMIT_NOTE.trimStart());
277
567
  return finalText;
278
568
  }
569
+ /** A Claude API failure the daemon reports with its own text and SDK error code. */
570
+ export class AgentRunError extends Error {
571
+ code;
572
+ resetsAt;
573
+ constructor(message, code, resetsAt) {
574
+ super(message);
575
+ this.code = code;
576
+ this.resetsAt = resetsAt;
577
+ this.name = 'AgentRunError';
578
+ }
579
+ }
580
+ /** An assistant message the SDK marked as an API failure (`error` set), or null. */
581
+ function extractApiError(message) {
582
+ const m = message;
583
+ if (m?.type !== 'assistant' || typeof m.error !== 'string' || !m.error)
584
+ return null;
585
+ const text = extractTerminalText(m) || `Claude API error (${m.error})`;
586
+ const resetsAt = [m.resetsAt, m.message?.resetsAt, m.quotaLimits?.resetsAt, m.rateLimit?.resetsAt].find((v) => typeof v === 'number' && Number.isFinite(v) && v > 0);
587
+ return { code: m.error, text, ...(resetsAt ? { resetsAt } : {}) };
588
+ }
589
+ /** A terminal `result` message: did the run fail, and with what text? Null for anything else. */
590
+ function resultOutcome(message) {
591
+ const m = message;
592
+ if (m?.type !== 'result')
593
+ return null;
594
+ if (m.is_error !== true)
595
+ return { isError: false, text: '' };
596
+ const text = typeof m.result === 'string' ? m.result : Array.isArray(m.errors) ? m.errors.join('; ') : '';
597
+ return { isError: true, text };
598
+ }
599
+ /** What to insert after `text` so the next turn starts a new paragraph. */
600
+ function paragraphGap(text) {
601
+ if (text.endsWith('\n\n'))
602
+ return '';
603
+ return text.endsWith('\n') ? '\n' : '\n\n';
604
+ }
605
+ /** The start of a new model turn: one `message_start` per assistant message. */
606
+ function isMessageStart(message) {
607
+ const m = message;
608
+ return m?.type === 'stream_event' && m.event?.type === 'message_start';
609
+ }
610
+ /** The SDK's terminal `result` when the run was stopped by maxTurns. */
611
+ function isTurnLimitResult(message) {
612
+ const m = message;
613
+ return m?.type === 'result' && m.subtype === 'error_max_turns';
614
+ }
615
+ /** Tool calls in a complete `assistant` message, with the URL or query each is about. */
616
+ function extractToolUses(message) {
617
+ const m = message;
618
+ if (m?.type !== 'assistant' || !Array.isArray(m.message?.content))
619
+ return [];
620
+ return m.message.content
621
+ .filter((b) => b?.type === 'tool_use' && typeof b.name === 'string')
622
+ .map((b) => ({ name: b.name, detail: toolCallDetail(b.name, b.input ?? {}) }));
623
+ }
624
+ /** Default sink: stderr, next to the canUseTool lines. */
625
+ function logStreamToolCall(name, detail) {
626
+ console.error(`[noesis-agent] tool_use ${name}${detail ? ` ${detail}` : ''}`);
627
+ }
279
628
  /**
280
629
  * Text carried by one streamed partial. Verified against the SDK (0.1.x): partials
281
630
  * arrive as `{type:'stream_event', event:{type:'content_block_delta',
@@ -310,6 +659,62 @@ function extractTerminalText(message) {
310
659
  }
311
660
  return '';
312
661
  }
662
+ /** The /complete body for a failed job: the message, plus the SDK's code when known. */
663
+ export function completionErrorBody(lease, err) {
664
+ const body = { lease, error: err instanceof Error ? err.message : String(err) };
665
+ if (err instanceof AgentRunError) {
666
+ body.errorCode = err.code;
667
+ if (err.resetsAt)
668
+ body.resetsAt = err.resetsAt;
669
+ }
670
+ return body;
671
+ }
672
+ /**
673
+ * A stand-in for the Agent SDK that emits the message sequences a real run does, so the
674
+ * fake modes exercise consumeAgentStream exactly like real traffic.
675
+ */
676
+ export function fakeSdk(mode, opts) {
677
+ return { query: () => fakeStream(mode, opts) };
678
+ }
679
+ async function* fakeStream(mode, opts) {
680
+ const now = (opts.now ?? Date.now)();
681
+ if (mode !== 'canned' && now < opts.resetAt) {
682
+ const text = mode === 'rate_limit'
683
+ ? `You've hit your session limit · resets ${formatResetLikeCli(opts.resetAt, opts.timeZone, now)}`
684
+ : 'OAuth token revoked · Please run /login';
685
+ yield { type: 'system', subtype: 'init' };
686
+ yield { type: 'assistant', message: { content: [{ type: 'text', text }] }, error: mode === 'rate_limit' ? 'rate_limit' : 'authentication_failed' };
687
+ yield { type: 'result', subtype: 'success', is_error: true, result: text };
688
+ if (opts.throwAtEnd !== false)
689
+ throw new Error('Claude Code process exited with code 1');
690
+ return;
691
+ }
692
+ const canned = `Noesis local agent (fake mode) processed a "${opts.kind}" job. Prompt was: ${opts.promptText.slice(0, 80)}`;
693
+ yield { type: 'stream_event', event: { type: 'message_start', message: {} } };
694
+ const words = canned.split(' ');
695
+ for (let i = 0; i < words.length; i++) {
696
+ yield { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: (i ? ' ' : '') + words[i] } } };
697
+ if (opts.wordMs > 0)
698
+ await new Promise((r) => setTimeout(r, opts.wordMs));
699
+ }
700
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: canned }] } };
701
+ yield { type: 'result', subtype: 'success', is_error: false, result: canned };
702
+ }
703
+ /**
704
+ * A reset time the way the Claude CLI prints it in its limit message: "3pm (Zone)",
705
+ * "3:07pm (Zone)", or "Oct 12, 3pm (Zone)" when more than a day away — minutes, no seconds.
706
+ */
707
+ export function formatResetLikeCli(atMs, timeZone, nowMs = Date.now()) {
708
+ const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
709
+ const parts = new Intl.DateTimeFormat('en-US', {
710
+ timeZone: zone, month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true,
711
+ }).formatToParts(new Date(atMs));
712
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? '';
713
+ const minute = get('minute');
714
+ const time = `${get('hour')}${minute && minute !== '00' ? `:${minute}` : ''}${get('dayPeriod').toLowerCase()}`;
715
+ const date = atMs - nowMs > 24 * 60 * 60 * 1000 ? `${get('month')} ${get('day')}, ` : '';
716
+ return `${date}${time} (${zone})`;
717
+ }
313
718
  async function runJob(cfg, job) {
314
719
  const sink = new EventSink(cfg, job.jobId, job.lease);
315
720
  sink.start();
@@ -324,12 +729,17 @@ async function runJob(cfg, job) {
324
729
  await sink.finish();
325
730
  if (sink.isClosed())
326
731
  return;
327
- await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, {
328
- lease: job.lease,
329
- error: err instanceof Error ? err.message : String(err),
330
- });
732
+ await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, completionErrorBody(job.lease, err));
331
733
  }
332
734
  }
735
+ /**
736
+ * The daemon's startup banner line — pulled out of runAgentDaemon() so the printed
737
+ * version string is directly unit-testable without starting the real (infinite) loop.
738
+ */
739
+ export function startupBanner(cfg) {
740
+ const fake = cfg.fake ? `, FAKE mode${cfg.fakeMode && cfg.fakeMode !== 'canned' ? ` (${cfg.fakeMode})` : ''}` : '';
741
+ return `[noesis-agent] v${agentVersion()} started (concurrency=${cfg.concurrency}${fake}) → ${cfg.apiBaseUrl}`;
742
+ }
333
743
  /** Run the daemon loop until aborted. */
334
744
  export async function runAgentDaemon(cfg, signal) {
335
745
  // Footgun guard: keep billing on the subscription, not the metered API.
@@ -337,7 +747,12 @@ export async function runAgentDaemon(cfg, signal) {
337
747
  console.error('[noesis-agent] WARNING: ANTHROPIC_API_KEY is set — unsetting it so the Claude Agent SDK uses your subscription, not the metered API.');
338
748
  delete process.env.ANTHROPIC_API_KEY;
339
749
  }
340
- console.error(`[noesis-agent] started (concurrency=${cfg.concurrency}${cfg.fake ? ', FAKE mode' : ''}) → ${cfg.apiBaseUrl}`);
750
+ for (const warning of webFetchEnvWarnings(process.env))
751
+ console.error(warning);
752
+ console.error(startupBanner(cfg));
753
+ // Confirm the API token before polling: an expired token is explained and waited out
754
+ // (it recovers after an Extend); a revoked or unknown one ends the process here.
755
+ applyStartupCheck(await checkToken(cfg.apiBaseUrl, cfg.apiToken), tokenGate);
341
756
  const workers = Array.from({ length: cfg.concurrency }, (_, i) => workerLoop(cfg, `worker-${i + 1}`, signal));
342
757
  await Promise.all(workers);
343
758
  }
@@ -355,7 +770,7 @@ async function workerLoop(cfg, workerId, signal) {
355
770
  await runJob(cfg, job);
356
771
  }
357
772
  else {
358
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
773
+ await sleep(tokenGate.waitMs(POLL_INTERVAL_MS), signal);
359
774
  }
360
775
  }
361
776
  }