@noesis-brain/mcp-server 2.1.5 → 2.5.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 (49) hide show
  1. package/README.md +10 -1
  2. package/dist/agent/identity.d.ts +33 -0
  3. package/dist/agent/identity.d.ts.map +1 -0
  4. package/dist/agent/identity.js +61 -0
  5. package/dist/agent/identity.js.map +1 -0
  6. package/dist/agent/runner.d.ts +103 -6
  7. package/dist/agent/runner.d.ts.map +1 -1
  8. package/dist/agent/runner.js +375 -64
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/tokenState.d.ts +81 -0
  11. package/dist/agent/tokenState.d.ts.map +1 -0
  12. package/dist/agent/tokenState.js +173 -0
  13. package/dist/agent/tokenState.js.map +1 -0
  14. package/dist/agent/webGuard.d.ts +48 -0
  15. package/dist/agent/webGuard.d.ts.map +1 -0
  16. package/dist/agent/webGuard.js +173 -0
  17. package/dist/agent/webGuard.js.map +1 -0
  18. package/dist/api/NoesisClient.d.ts +19 -1
  19. package/dist/api/NoesisClient.d.ts.map +1 -1
  20. package/dist/api/NoesisClient.js +55 -3
  21. package/dist/api/NoesisClient.js.map +1 -1
  22. package/dist/cli/setup.js +1 -1
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/machineLabel.d.ts +16 -0
  25. package/dist/machineLabel.d.ts.map +1 -0
  26. package/dist/machineLabel.js +43 -0
  27. package/dist/machineLabel.js.map +1 -0
  28. package/dist/tools/backlog.d.ts +21 -0
  29. package/dist/tools/backlog.d.ts.map +1 -1
  30. package/dist/tools/backlog.js +123 -1
  31. package/dist/tools/backlog.js.map +1 -1
  32. package/dist/tools/claudeSessions.d.ts +83 -0
  33. package/dist/tools/claudeSessions.d.ts.map +1 -0
  34. package/dist/tools/claudeSessions.js +330 -0
  35. package/dist/tools/claudeSessions.js.map +1 -0
  36. package/dist/tools/index.d.ts +1 -6
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +166 -37
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/sessionIndex.d.ts +66 -0
  41. package/dist/tools/sessionIndex.d.ts.map +1 -0
  42. package/dist/tools/sessionIndex.js +241 -0
  43. package/dist/tools/sessionIndex.js.map +1 -0
  44. package/package.json +1 -1
  45. package/scripts/noesis-capture-watcher.mjs +722 -107
  46. package/scripts/start-agent.sh +51 -12
  47. package/skill-templates/noesis-refine-relations.md +163 -0
  48. package/skill-templates/noesis-ticket.md +79 -0
  49. package/templates/claude-md-block.md +7 -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;
@@ -211,14 +296,16 @@ export function buildSdkPrompt(prompt, images) {
211
296
  /**
212
297
  * SECOND, INDEPENDENT LAYER on the tool boundary.
213
298
  *
214
- * `tools: []` below is the primary control, but it works by way of the CLI's `--tools ""`
215
- * flag, whose handling lives in a minified `cli.js` we cannot audit. This callback is a
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
216
302
  * gate we own outright: the SDK routes every permission request through it
217
303
  * (`--permission-prompt-tool stdio`), and the request carries an `agentID`, so calls made
218
304
  * by a spawned `Task` SUBAGENT are covered too — which matters, because a subagent, not
219
305
  * the main loop, did the file reading in the 2026-08-14 incident.
220
306
  *
221
- * Deliberately reuses ALLOWED_TOOLS so the daemon has ONE definition of what may run.
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.
222
309
  *
223
310
  * DO NOT DELETE THIS AS DEAD CODE. It is the ONLY daemon-owned control over MCP tools.
224
311
  * `tools: []` governs the SDK's BUILT-IN set and nothing else; the Noesis MCP server we
@@ -240,6 +327,13 @@ export function buildSdkPrompt(prompt, images) {
240
327
  * callback is never consulted there. Nothing executes — but that is Claude Code internal
241
328
  * behaviour we neither own nor test, which is why the daemon is rooted at a neutral cwd
242
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.
243
337
  */
244
338
  export function buildCanUseTool(
245
339
  /**
@@ -251,44 +345,69 @@ export function buildCanUseTool(
251
345
  * The sets happen to coincide for chat today, which is exactly why it would have gone
252
346
  * unnoticed until the first job kind with a narrower allowlist.
253
347
  */
254
- permitted, onDecision = logToolDecision) {
348
+ permitted, onDecision = logToolDecision, checkUrl = checkWebFetchUrl) {
255
349
  const permittedSet = new Set(permitted);
256
350
  return async (toolName, input, ctx) => {
257
- const allowed = permittedSet.has(toolName);
258
- onDecision(toolName, allowed, ctx?.agentID);
259
- return allowed
260
- ? { behavior: 'allow', updatedInput: (input ?? {}) }
261
- : { behavior: 'deny', message: `Tool "${toolName}" is not permitted for a Noesis job.` };
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 };
262
368
  };
263
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
+ }
264
375
  /** Default sink: stderr, so the daemon log is the evidence for whether the gate ever fires. */
265
- function logToolDecision(toolName, allowed, agentID) {
266
- console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${agentID ? ` (agent=${agentID})` : ''}`);
376
+ function logToolDecision(toolName, allowed, agentID, detail) {
377
+ console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${detail ? ` ${detail}` : ''}${agentID ? ` (agent=${agentID})` : ''}`);
267
378
  }
268
379
  /**
269
380
  * The `options` object handed to sdk.query() — extracted, like buildSdkPrompt above, so
270
- * the tool boundary is asserted by a test instead of resting on a comment. `tools: []`
271
- * is the security-relevant field: see the note at its assignment. Takes the MCP-server
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
272
383
  * factory as an argument so a test can supply a stub instead of a real spawn config.
273
384
  */
274
385
  export function buildQueryOptions(payload, mcpServersFor) {
275
- const allowedTools = clampAllowedTools(payload.allowedTools);
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)));
276
391
  return {
277
392
  ...(payload.system ? { systemPrompt: payload.system } : {}),
278
- // Suppress the SDK's built-in tool set entirely (`--tools ""`). Without it the CLI
279
- // defaults to ALL built-ins — Bash, Read, Write, Edit, Grep, WebSearch — and
280
- // ALLOWED_TOOLS above does NOT stop them: `allowedTools` is a PERMISSION allowlist,
281
- // so unlisted tools are un-pre-approved, not absent, and their definitions still
282
- // reach the model. Worse, the read-only built-ins need no approval at all, so they
283
- // actually EXECUTE headless: on 2026-08-14 an English-coach Navi grepped this
284
- // daemon's cwd and returned ~100 strings from the user's source tree (jobs 527/528);
285
- // only the follow-up file Write stopped, at the permission gate. Unconditional by
286
- // design — a server-supplied payload must never be able to ask for a filesystem
287
- // primitive. MCP tools travel a separate channel (`--mcp-config`) and are
288
- // unaffected: a `use_knowledge_base` Navi still calls search_notes (verified).
289
- tools: [],
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,
290
407
  // Independent second layer — see buildCanUseTool. Covers subagent (`Task`) calls too.
291
- canUseTool: buildCanUseTool(allowedTools),
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]),
292
411
  allowedTools,
293
412
  // Only spawn the Noesis MCP server when a tool actually survived clamping.
294
413
  ...(allowedTools.length > 0 ? { mcpServers: mcpServersFor() } : {}),
@@ -325,14 +444,13 @@ export function runSdkQuery(sdk, payload, kindLabel, mcpServersFor) {
325
444
  async function executeJob(cfg, job, sink) {
326
445
  const prompt = resolveJobPrompt(job.payload, job.kind);
327
446
  if (cfg.fake) {
328
- const canned = `Noesis local agent (fake mode) processed a "${job.kind}" job. Prompt was: ${prompt.slice(0, 80)}`;
329
- for (const word of canned.split(' ')) {
330
- if (sink.isClosed())
331
- break;
332
- sink.push(word + ' ');
333
- await new Promise((r) => setTimeout(r, 15));
334
- }
335
- 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);
336
454
  }
337
455
  // Real path: the Agent SDK uses the ambient Claude Code subscription login.
338
456
  // Dynamically imported via a NON-LITERAL specifier so tsc doesn't require the
@@ -343,36 +461,170 @@ async function executeJob(cfg, job, sink) {
343
461
  const sdk = await import(sdkModule).catch(() => {
344
462
  throw new Error('Install @anthropic-ai/claude-agent-sdk to run the Noesis local agent (real mode).');
345
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) {
346
488
  let finalText = '';
347
489
  let sawDelta = false;
348
490
  let terminalText = '';
349
- const stream = runSdkQuery(sdk, job.payload, job.kind, () => noesisMcpServers(cfg));
350
- // With includePartialMessages the stream carries BOTH incremental `stream_event`
351
- // deltas AND a terminal `assistant` message holding the whole reply. Taking both
352
- // would emit the answer twice, so deltas win and the terminal message is only a
353
- // fallback for a stream that produced none (older SDK, or partials unsupported).
354
- for await (const message of stream) {
355
- if (sink.isClosed())
356
- break;
357
- const delta = extractStreamDelta(message);
358
- if (delta) {
359
- sawDelta = true;
360
- sink.push(delta);
361
- finalText += delta;
362
- 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;
363
551
  }
364
- // Concatenate, don't overwrite: with tools the run is multi-turn, and a
365
- // stream that produced no deltas would otherwise keep only the LAST turn.
366
- const whole = extractTerminalText(message);
367
- if (whole)
368
- terminalText += whole;
552
+ }
553
+ catch (e) {
554
+ throw failure() ?? e;
555
+ }
556
+ if (!succeeded) {
557
+ const f = failure();
558
+ if (f)
559
+ throw f;
369
560
  }
370
561
  if (!sawDelta && terminalText) {
371
- sink.push(terminalText);
562
+ out.push(terminalText);
372
563
  finalText = terminalText;
373
564
  }
565
+ if (hitTurnLimit && !out.isClosed())
566
+ emit(finalText ? TURN_LIMIT_NOTE : TURN_LIMIT_NOTE.trimStart());
374
567
  return finalText;
375
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
+ }
376
628
  /**
377
629
  * Text carried by one streamed partial. Verified against the SDK (0.1.x): partials
378
630
  * arrive as `{type:'stream_event', event:{type:'content_block_delta',
@@ -407,6 +659,62 @@ function extractTerminalText(message) {
407
659
  }
408
660
  return '';
409
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
+ }
410
718
  async function runJob(cfg, job) {
411
719
  const sink = new EventSink(cfg, job.jobId, job.lease);
412
720
  sink.start();
@@ -421,10 +729,7 @@ async function runJob(cfg, job) {
421
729
  await sink.finish();
422
730
  if (sink.isClosed())
423
731
  return;
424
- await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, {
425
- lease: job.lease,
426
- error: err instanceof Error ? err.message : String(err),
427
- });
732
+ await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, completionErrorBody(job.lease, err));
428
733
  }
429
734
  }
430
735
  /**
@@ -432,7 +737,8 @@ async function runJob(cfg, job) {
432
737
  * version string is directly unit-testable without starting the real (infinite) loop.
433
738
  */
434
739
  export function startupBanner(cfg) {
435
- return `[noesis-agent] v${agentVersion()} started (concurrency=${cfg.concurrency}${cfg.fake ? ', FAKE mode' : ''}) → ${cfg.apiBaseUrl}`;
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}`;
436
742
  }
437
743
  /** Run the daemon loop until aborted. */
438
744
  export async function runAgentDaemon(cfg, signal) {
@@ -441,7 +747,12 @@ export async function runAgentDaemon(cfg, signal) {
441
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.');
442
748
  delete process.env.ANTHROPIC_API_KEY;
443
749
  }
750
+ for (const warning of webFetchEnvWarnings(process.env))
751
+ console.error(warning);
444
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);
445
756
  const workers = Array.from({ length: cfg.concurrency }, (_, i) => workerLoop(cfg, `worker-${i + 1}`, signal));
446
757
  await Promise.all(workers);
447
758
  }
@@ -459,7 +770,7 @@ async function workerLoop(cfg, workerId, signal) {
459
770
  await runJob(cfg, job);
460
771
  }
461
772
  else {
462
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
773
+ await sleep(tokenGate.waitMs(POLL_INTERVAL_MS), signal);
463
774
  }
464
775
  }
465
776
  }