@ouro.bot/cli 0.1.0-alpha.955 → 0.1.0-alpha.957

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.
@@ -259,8 +259,8 @@ exports.COMMAND_REGISTRY = {
259
259
  },
260
260
  auth: {
261
261
  category: "Auth",
262
- description: "Set up or verify agent credentials",
263
- usage: "ouro auth [verify] [--agent <name>] [--provider <provider>]",
262
+ description: "Set up or verify agent credentials. For Anthropic API-key mode (for example through a credit gateway), pass --api-key-file <path> (use /dev/stdin to pipe it) and optionally --base-url <url>; the key is never taken from the command line.",
263
+ usage: "ouro auth [verify] [--agent <name>] [--provider <provider>] [--api-key-file <path> [--base-url <url>]]",
264
264
  example: "ouro auth",
265
265
  subcommands: ["verify"],
266
266
  },
@@ -358,6 +358,13 @@ exports.COMMAND_REGISTRY = {
358
358
  example: "ouro session list",
359
359
  subcommands: ["list"],
360
360
  },
361
+ gateway: {
362
+ category: "System",
363
+ description: "Run the credit gateway: one master key for agents, upstream Anthropic API keys spent in a fixed order",
364
+ usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>]",
365
+ example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys",
366
+ subcommands: ["serve"],
367
+ },
361
368
  mcp: {
362
369
  category: "System",
363
370
  description: "Interact with MCP servers",
@@ -497,6 +504,11 @@ const SUBCOMMAND_HELP = {
497
504
  usage: "ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
498
505
  example: "ouro a2a onboard --agent <agent> --card-url https://peer.example/.well-known/agent-card.json --trust friend",
499
506
  },
507
+ "gateway serve": {
508
+ description: "Run the credit gateway in the foreground. It accepts the master key from <keys-dir>/master.key and forwards each request to the Anthropic API with the first upstream *.key (by file name) whose credits are not exhausted; a key whose credit balance is too low is skipped for the cooldown (default 60 minutes). GET /health needs no key.",
509
+ usage: "ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
510
+ example: "ouro gateway serve --keys-dir /mnt/user/appdata/credit-gateway/keys --port 8787",
511
+ },
500
512
  "a2a serve": {
501
513
  description: "Run this agent's local A2A JSON-RPC sense server",
502
514
  usage: "ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
@@ -254,6 +254,7 @@ function usage() {
254
254
  " ouro mailbox [--json]",
255
255
  " ouro -v|--version",
256
256
  " ouro auth [--agent <name>] [--provider <provider>]",
257
+ " ouro auth --agent <name> --provider anthropic --api-key-file <path> [--base-url <url>]",
257
258
  " ouro account ensure [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
258
259
  " ouro connect [providers|perplexity|embeddings|teams|bluebubbles|mail|voice|a2a|telegram|workbench] [--agent <name>] [--owner-email <email> --source <label>|--no-delegated-source] [--rotate-missing-mail-keys]",
259
260
  " ouro mail import-mbox --file <path> [--owner-email <email>] [--source <label>] [--agent <name>] [--foreground]",
@@ -303,6 +304,7 @@ function usage() {
303
304
  " ouro a2a card [--agent <name>] [--base-url <url>] [--json]",
304
305
  " ouro a2a onboard [--agent <name>] (--card-url <url>|--did <did:key> --name <name>) [--trust <level>] [--name <name>]",
305
306
  " ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
307
+ " ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]",
306
308
  " ouro a2a identity [--identity-file <path>] [--json]",
307
309
  " ouro a2a message --to <card-url> --text <text> [--context <id>] [--delegated] [--identity-file <path>] [--json]",
308
310
  " ouro a2a outbox list --to <card-url> [--since <cursor>] [--identity-file <path>] [--json]",
@@ -834,6 +836,8 @@ function parseAuthCommand(args) {
834
836
  }
835
837
  const { agent, rest } = extractAgentFlag(args);
836
838
  let provider;
839
+ let apiKeyFile;
840
+ let baseUrl;
837
841
  for (let i = 0; i < rest.length; i += 1) {
838
842
  if (rest[i] === "--provider") {
839
843
  const value = rest[i + 1];
@@ -843,11 +847,33 @@ function parseAuthCommand(args) {
843
847
  i += 1;
844
848
  continue;
845
849
  }
850
+ // The API key itself is read from a file (or /dev/stdin), never from argv, so it stays out of
851
+ // shell history and process listings.
852
+ if (rest[i] === "--api-key-file" || rest[i] === "--base-url") {
853
+ const value = rest[i + 1];
854
+ if (!value || value.startsWith("--"))
855
+ throw new Error(`${rest[i]} requires a value.`);
856
+ if (rest[i] === "--api-key-file")
857
+ apiKeyFile = value;
858
+ else
859
+ baseUrl = value;
860
+ i += 1;
861
+ continue;
862
+ }
863
+ }
864
+ if ((apiKeyFile || baseUrl) && provider !== "anthropic") {
865
+ throw new Error("--api-key-file and --base-url are only for --provider anthropic (API-key mode).");
846
866
  }
867
+ if (baseUrl && !apiKeyFile)
868
+ throw new Error("--base-url requires --api-key-file.");
869
+ if (baseUrl && !/^https?:\/\//.test(baseUrl))
870
+ throw new Error("--base-url must be an http(s) URL.");
847
871
  return {
848
872
  kind: "auth.run",
849
873
  ...(agent ? { agent } : {}),
850
874
  ...(provider ? { provider } : {}),
875
+ ...(apiKeyFile ? { apiKeyFile } : {}),
876
+ ...(baseUrl ? { baseUrl } : {}),
851
877
  };
852
878
  }
853
879
  function isVaultUnlockStoreKind(value) {
@@ -1936,6 +1962,61 @@ function parseA2ACommand(args) {
1936
1962
  }
1937
1963
  throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation|delegated-commands ...");
1938
1964
  }
1965
+ const GATEWAY_SERVE_USAGE = "Usage: ouro gateway serve --keys-dir <dir> [--host <host>] [--port <port>] [--state <path>] [--upstream <url>] [--cooldown-minutes <n>]";
1966
+ function parseGatewayCommand(args) {
1967
+ const [sub, ...rest] = args;
1968
+ if (sub !== "serve")
1969
+ throw new Error(GATEWAY_SERVE_USAGE);
1970
+ let keysDir;
1971
+ let host;
1972
+ let port;
1973
+ let statePath;
1974
+ let upstream;
1975
+ let cooldownMinutes;
1976
+ for (let i = 0; i < rest.length; i += 1) {
1977
+ const flag = rest[i];
1978
+ const value = rest[i + 1];
1979
+ if (!value || value.startsWith("--"))
1980
+ throw new Error(GATEWAY_SERVE_USAGE);
1981
+ i += 1;
1982
+ if (flag === "--keys-dir")
1983
+ keysDir = value;
1984
+ else if (flag === "--host")
1985
+ host = value;
1986
+ else if (flag === "--state")
1987
+ statePath = value;
1988
+ else if (flag === "--upstream") {
1989
+ if (!/^https?:\/\//.test(value))
1990
+ throw new Error("--upstream must be an http(s) URL");
1991
+ upstream = value;
1992
+ }
1993
+ else if (flag === "--port") {
1994
+ const parsed = Number(value);
1995
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535)
1996
+ throw new Error("Gateway port must be 0-65535");
1997
+ port = parsed;
1998
+ }
1999
+ else if (flag === "--cooldown-minutes") {
2000
+ const parsed = Number(value);
2001
+ if (!Number.isFinite(parsed) || parsed <= 0)
2002
+ throw new Error("--cooldown-minutes must be a positive number");
2003
+ cooldownMinutes = parsed;
2004
+ }
2005
+ else
2006
+ throw new Error(GATEWAY_SERVE_USAGE);
2007
+ }
2008
+ if (!keysDir)
2009
+ throw new Error(GATEWAY_SERVE_USAGE);
2010
+ return {
2011
+ kind: "gateway.serve",
2012
+ keysDir,
2013
+ ...(host ? { host } : {}),
2014
+ ...(port !== undefined ? { port } : {}),
2015
+ ...(statePath ? { statePath } : {}),
2016
+ ...(upstream ? { upstream } : {}),
2017
+ ...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
2018
+ };
2019
+ }
1939
2020
  function parseConfigCommand(args) {
1940
2021
  const { agent, rest: afterAgent } = extractAgentFlag(args);
1941
2022
  const { facing, rest: cleaned } = extractFacingFlag(afterAgent);
@@ -3118,6 +3199,8 @@ function parseOuroCommand(args) {
3118
3199
  return parseFriendCommand(args.slice(1));
3119
3200
  if (head === "a2a")
3120
3201
  return parseA2ACommand(args.slice(1));
3202
+ if (head === "gateway")
3203
+ return parseGatewayCommand(args.slice(1));
3121
3204
  if (head === "config")
3122
3205
  return parseConfigCommand(args.slice(1));
3123
3206
  if (head === "mcp")
@@ -813,8 +813,13 @@ function recordExternalEventInternal(input, options = {}) {
813
813
  && existing.recoveryGrant?.generation === existing.generation && existing.failureProvenance !== undefined
814
814
  && Date.parse(now) - Date.parse(existing.failureProvenance.failedAt) >= EXHAUSTED_RECOVERY_REOPEN_MS
815
815
  && Math.floor(Date.parse(now) / 3_600_000) % EXHAUSTED_RECOVERY_SLOTS === exhaustedRecoverySlot(existing);
816
- const shouldWake = !quietInitialReceipt && (!existing || quietBaselineChanged
816
+ // A recovery is only news to an owner who was told about the incident, or to a turn that asked to watch for it; any other recovery settles here without a model turn.
817
+ const silentRecovery = input.transition === "recovered" && existing !== null
818
+ && existing.disposition?.decision !== "ask" && existing.disposition?.decision !== "report" && existing.disposition?.nextWake.kind !== "on_recovery";
819
+ const settledRecoveryReopened = existing?.executionState === "handled" && !existing.disposition && existing.transition === "recovered" && input.transition !== "recovered";
820
+ const shouldWake = !quietInitialReceipt && !silentRecovery && (!existing || quietBaselineChanged || settledRecoveryReopened
817
821
  || (existing.disposition ? predicateMatches(existing.disposition, input, revision) : existing.executionState === "dead_letter" && (revision !== existing.observationRevision || exhaustedRecoveryReopens)));
822
+ const settles = quietInitialReceipt || silentRecovery;
818
823
  const generation = existing ? existing.generation + (shouldWake ? 1 : 0) : 1;
819
824
  const record = {
820
825
  schemaVersion: 2,
@@ -834,17 +839,17 @@ function recordExternalEventInternal(input, options = {}) {
834
839
  observationRevision: revision,
835
840
  observationDigest: digest,
836
841
  transition: input.transition ?? (existing ? "unchanged" : "opened"),
837
- executionState: quietInitialReceipt ? "handled" : shouldWake ? "received" : existing.executionState,
842
+ executionState: settles ? "handled" : shouldWake ? "received" : existing.executionState,
838
843
  generation,
839
- attemptCount: shouldWake || quietInitialReceipt ? 0 : existing.attemptCount,
840
- claimOwner: shouldWake || quietInitialReceipt ? null : existing.claimOwner,
841
- claimExpiresAt: shouldWake || quietInitialReceipt ? null : existing.claimExpiresAt,
842
- nextAttemptAt: shouldWake || quietInitialReceipt ? null : existing.nextAttemptAt,
843
- lastError: shouldWake || quietInitialReceipt ? null : existing.lastError,
844
- ...(!shouldWake && !quietInitialReceipt && existing?.failureProvenance ? { failureProvenance: existing.failureProvenance } : {}),
845
- ...(!shouldWake && !quietInitialReceipt && existing?.recoveryGrant ? { recoveryGrant: existing.recoveryGrant } : {}),
846
- disposition: shouldWake || quietInitialReceipt ? null : existing.disposition,
847
- pendingObservation: shouldWake || quietInitialReceipt ? null : existing.pendingObservation,
844
+ attemptCount: shouldWake || settles ? 0 : existing.attemptCount,
845
+ claimOwner: shouldWake || settles ? null : existing.claimOwner,
846
+ claimExpiresAt: shouldWake || settles ? null : existing.claimExpiresAt,
847
+ nextAttemptAt: shouldWake || settles ? null : existing.nextAttemptAt,
848
+ lastError: shouldWake || settles ? null : existing.lastError,
849
+ ...(!shouldWake && !settles && existing?.failureProvenance ? { failureProvenance: existing.failureProvenance } : {}),
850
+ ...(!shouldWake && !settles && existing?.recoveryGrant ? { recoveryGrant: existing.recoveryGrant } : {}),
851
+ disposition: shouldWake || settles ? null : existing.disposition,
852
+ pendingObservation: shouldWake || settles ? null : existing.pendingObservation,
848
853
  dispatchEnabled: options.dispatchEnabled ?? existing?.dispatchEnabled ?? true,
849
854
  shouldWake,
850
855
  ...(persistedRetention ? { retentionSummary: persistedRetention } : {}),
@@ -1399,7 +1404,10 @@ function settleExternalEventFailureIfOwned(recordPath, input) {
1399
1404
  || record.generation !== input.expectedGeneration) {
1400
1405
  return { settled: false, record };
1401
1406
  }
1402
- const settled = commitMutation(recordPath, retryState(record, now, 5, 1_000, input.error, input.failureClass), now);
1407
+ const next = input.deferUntil
1408
+ ? { ...record, executionState: "retry_wait", claimOwner: null, claimExpiresAt: null, nextAttemptAt: input.deferUntil, attemptCount: Math.max(0, record.attemptCount - 1), lastError: input.error.slice(0, 1_000), shouldWake: false }
1409
+ : retryState(record, now, 5, 1_000, input.error, input.failureClass);
1410
+ const settled = commitMutation(recordPath, next, now);
1403
1411
  return { settled: true, record: settled };
1404
1412
  });
1405
1413
  }
@@ -69,7 +69,7 @@ var autonomy_budget_2 = require("./autonomy-budget");
69
69
  Object.defineProperty(exports, "normalizeHabitPaidTurnsPerDay", { enumerable: true, get: function () { return autonomy_budget_2.normalizeHabitPaidTurnsPerDay; } });
70
70
  /** Single source of truth for per-provider credential field names, env var mappings, and prompt labels. */
71
71
  exports.PROVIDER_CREDENTIALS = {
72
- anthropic: { required: ["setupToken"], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
72
+ anthropic: { required: ["setupToken"], alternativeRequired: [["apiKey"]], envVars: { ANTHROPIC_API_KEY: "setupToken" }, promptLabels: { setupToken: "Anthropic setup-token" } },
73
73
  "openai-codex": { required: ["oauthAccessToken"], envVars: { OPENAI_API_KEY: "oauthAccessToken" }, promptLabels: { oauthAccessToken: "OpenAI Codex OAuth token" } },
74
74
  azure: { required: ["apiKey", "endpoint", "deployment"], envVars: { AZURE_OPENAI_API_KEY: "apiKey", AZURE_OPENAI_KEY: "apiKey", AZURE_OPENAI_ENDPOINT: "endpoint", AZURE_OPENAI_DEPLOYMENT: "deployment" }, promptLabels: { apiKey: "Azure API key", endpoint: "Azure endpoint", deployment: "Azure deployment" } },
75
75
  minimax: { required: ["apiKey"], envVars: { MINIMAX_API_KEY: "apiKey" }, promptLabels: { apiKey: "MiniMax API key" } },
@@ -59,8 +59,8 @@ const VAULT_ITEM_PREFIX = "providers/";
59
59
  exports.PROVIDER_CREDENTIAL_POOL_NOT_LOADED_ERROR = "provider credentials have not been loaded from vault";
60
60
  const PROVIDER_FIELD_SPLITS = {
61
61
  anthropic: {
62
- credentials: ["setupToken", "refreshToken", "expiresAt"],
63
- config: [],
62
+ credentials: ["setupToken", "refreshToken", "expiresAt", "apiKey"],
63
+ config: ["baseUrl"],
64
64
  },
65
65
  "openai-codex": {
66
66
  credentials: ["oauthAccessToken", "refreshToken", "expiresAt"],
@@ -157,7 +157,8 @@ function hasEmptyCredentials(provider, config) {
157
157
  if (hasManagedIdentity)
158
158
  return false;
159
159
  }
160
- return identity_1.PROVIDER_CREDENTIALS[provider].required.some((key) => !record[key]);
160
+ const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
161
+ return ![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
161
162
  }
162
163
  function createProviderRuntimeForConfig(provider, config, options = {}) {
163
164
  // Use the same provider defaults as auth switch and hatch so verification
@@ -225,7 +225,16 @@ function mergeAnthropicToolArguments(current, partial) {
225
225
  }
226
226
  return current + partial;
227
227
  }
228
+ // An org whose prepaid API credits are used up gets HTTP 400 (or 402) with this phrase. That is a
229
+ // usage limit, not a malformed request, so failover tells the human the right thing.
230
+ const ANTHROPIC_CREDIT_LOW_PHRASE = "credit balance is too low";
231
+ function isAnthropicCreditExhausted(error) {
232
+ const status = error.status;
233
+ return (status === 400 || status === 402) && error.message.toLowerCase().includes(ANTHROPIC_CREDIT_LOW_PHRASE);
234
+ }
228
235
  function classifyAnthropicError(error) {
236
+ if (isAnthropicCreditExhausted(error))
237
+ return "usage-limit";
229
238
  return (0, error_classification_1.classifyHttpError)(error, {
230
239
  isAuthFailure: isAnthropicAuthFailure,
231
240
  isServerError: (e) => e.status === 529,
@@ -236,9 +245,10 @@ function isAnthropicAuthFailure(error) {
236
245
  return (lower.includes("oauth authentication") ||
237
246
  lower.includes("authentication failed") ||
238
247
  lower.includes("unauthorized") ||
239
- lower.includes("invalid api key"));
248
+ lower.includes("invalid api key") ||
249
+ lower.includes("invalid x-api-key"));
240
250
  }
241
- async function streamAnthropicMessages(client, model, request) {
251
+ async function streamAnthropicMessages(client, model, request, options = { claudeCodePreamble: true }) {
242
252
  const { system, messages } = toAnthropicMessages(request.messages);
243
253
  const additionalSystemParts = request.messages
244
254
  .filter((message) => message.role === "system")
@@ -260,13 +270,14 @@ async function streamAnthropicMessages(client, model, request) {
260
270
  // prompt when using OAuth setup tokens (sk-ant-oat01). Without it, Opus/Sonnet
261
271
  // 4.6 requests are rejected with 400. This is the API's validation that the
262
272
  // token is being used by a Claude Code client.
263
- const preambleText = "You are Claude Code, Anthropic's official CLI for Claude.";
273
+ // API-key mode (a plain API key, for example through a credit gateway) needs no such block.
274
+ const preambleText = options.claudeCodePreamble ? "You are Claude Code, Anthropic's official CLI for Claude." : "";
264
275
  if (request.systemPrompt) {
265
276
  // Structured SystemPrompt: merge preamble + stable prefix into one cached block,
266
277
  // volatile suffix as a separate uncached block.
267
278
  const stableBlock = {
268
279
  type: "text",
269
- text: preambleText + "\n\n" + request.systemPrompt.stable,
280
+ text: preambleText ? preambleText + "\n\n" + request.systemPrompt.stable : request.systemPrompt.stable,
270
281
  cache_control: { type: "ephemeral" },
271
282
  };
272
283
  params.system = [
@@ -279,9 +290,11 @@ async function streamAnthropicMessages(client, model, request) {
279
290
  }
280
291
  else if (system) {
281
292
  // Fallback: no structured prompt, extract from messages (legacy path)
282
- params.system = [{ type: "text", text: preambleText }, { type: "text", text: system }];
293
+ params.system = preambleText
294
+ ? [{ type: "text", text: preambleText }, { type: "text", text: system }]
295
+ : [{ type: "text", text: system }];
283
296
  }
284
- else {
297
+ else if (preambleText) {
285
298
  params.system = [{ type: "text", text: preambleText }];
286
299
  }
287
300
  if (anthropicTools.length > 0)
@@ -441,6 +454,48 @@ async function streamAnthropicMessages(client, model, request) {
441
454
  ...(settleFinalization ? { settleFinalization } : {}),
442
455
  };
443
456
  }
457
+ const ANTHROPIC_PING_MODEL = "claude-haiku-4-5-20251001";
458
+ /**
459
+ * API-key mode: a plain Anthropic API key, optionally against another base URL such as a credit
460
+ * gateway. No Claude Code identity headers, no Claude Code system preamble and no token refresh;
461
+ * prompt caching, thinking, tools and streaming work as in setup-token mode.
462
+ */
463
+ function createAnthropicApiKeyRuntime(model, apiKey, baseUrl) {
464
+ const modelCaps = (0, model_capabilities_1.getModelCapabilities)(model);
465
+ const capabilities = new Set();
466
+ if (modelCaps.reasoningEffort)
467
+ capabilities.add("reasoning-effort");
468
+ // authToken: null keeps the SDK from also sending an ANTHROPIC_AUTH_TOKEN from the environment.
469
+ const client = new sdk_1.default({ apiKey, authToken: null, baseURL: baseUrl, maxRetries: 0 });
470
+ (0, runtime_1.emitNervesEvent)({
471
+ component: "engine",
472
+ event: "engine.anthropic_api_key_mode",
473
+ message: "anthropic provider using API-key mode",
474
+ meta: { provider: "anthropic", customBaseUrl: baseUrl !== undefined },
475
+ });
476
+ return {
477
+ id: "anthropic",
478
+ model,
479
+ client,
480
+ capabilities,
481
+ supportedReasoningEfforts: modelCaps.reasoningEffort,
482
+ resetTurnState(_messages) {
483
+ // Anthropic request payload is derived from canonical messages each turn.
484
+ },
485
+ appendToolOutput(_callId, _output) {
486
+ // Anthropic uses canonical messages for tool_result tracking.
487
+ },
488
+ streamTurn(request) {
489
+ return streamAnthropicMessages(client, model, request, { claudeCodePreamble: false });
490
+ },
491
+ async ping(signal) {
492
+ await client.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal });
493
+ },
494
+ classifyError(error) {
495
+ return classifyAnthropicError(error);
496
+ },
497
+ };
498
+ }
444
499
  function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.getAnthropicConfig)()) {
445
500
  (0, runtime_1.emitNervesEvent)({
446
501
  component: "engine",
@@ -448,6 +503,9 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
448
503
  message: "anthropic provider init",
449
504
  meta: { provider: "anthropic" },
450
505
  });
506
+ const apiKey = anthropicConfig.apiKey?.trim();
507
+ if (apiKey)
508
+ return createAnthropicApiKeyRuntime(model, apiKey, anthropicConfig.baseUrl?.trim() || undefined);
451
509
  if (!anthropicConfig.setupToken) {
452
510
  throw new Error(getAnthropicReauthGuidance("provider 'anthropic' is selected but anthropic.setupToken is missing in the agent vault."));
453
511
  }
@@ -509,7 +567,7 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
509
567
  /* v8 ignore start -- ping: tested via provider-ping.test.ts @preserve */
510
568
  async ping(signal) {
511
569
  const freshClient = await ensureClient();
512
- await freshClient.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
570
+ await freshClient.messages.create({ model: ANTHROPIC_PING_MODEL, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
513
571
  },
514
572
  /* v8 ignore stop */
515
573
  /* v8 ignore next 3 -- delegation: classification logic tested via classifyAnthropicError @preserve */
@@ -108,7 +108,8 @@ function isProviderCredentialRecord(value) {
108
108
  const provider = value.provider;
109
109
  const canonicalCredentials = (0, provider_credentials_1.splitProviderCredentialFields)(provider, credentials).credentials;
110
110
  const canonicalConfig = (0, provider_credentials_1.splitProviderCredentialFields)(provider, config).config;
111
- if (!identity_1.PROVIDER_CREDENTIALS[provider].required.every((field) => (isPresentCredentialValue(canonicalCredentials[field]) || isPresentCredentialValue(canonicalConfig[field]))))
111
+ const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
112
+ if (![requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((field) => (isPresentCredentialValue(canonicalCredentials[field]) || isPresentCredentialValue(canonicalConfig[field])))))
112
113
  return false;
113
114
  const provenance = value.provenance;
114
115
  if (!isRecord(provenance))
@@ -185,7 +185,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
185
185
  id, area: "downloads", severity: "warn", refs,
186
186
  next: series ? "fix" : "owner",
187
187
  summary: `${label} download of ${name} is stuck at ${percent}% (${release}): ${problem}`,
188
- fingerprint: fingerprintOf(id, problem.startsWith("no progress") ? "frozen" : problem),
188
+ fingerprint: fingerprintOf(id, "stuck"),
189
189
  ...(series ? { fix: { tool: "media_fill_missing", args: { service_id: subjectId }, note: "keeps the partial download; replaces it only when a seeded alternative is grabbed" } } : {}),
190
190
  });
191
191
  }
@@ -228,7 +228,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
228
228
  findings.push({
229
229
  id, area: "missing", severity: "info", next: "fix",
230
230
  summary: series ? `${entry.name}: ${entry.count} monitored episode${entry.count === 1 ? "" : "s"} missing, the oldest aired ${days} days ago` : `${entry.name}: monitored movie still missing after ${days} days`,
231
- fingerprint: fingerprintOf(id, String(entry.count)),
231
+ fingerprint: fingerprintOf(id, "missing"),
232
232
  fix: series
233
233
  ? { tool: "media_fill_missing", args: { service_id: subjectId }, note: "searches, grabs a seeded release, imports what finished" }
234
234
  : { tool: "media_search_now", args: { kind: "movie", service_id: subjectId }, note: "triggers a Radarr search" },
@@ -272,7 +272,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
272
272
  const capacity = (id, label, percent) => {
273
273
  if (percent === null || percent < exports.CAPACITY_PERCENT)
274
274
  return;
275
- findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id, String(Math.floor(percent))) });
275
+ findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id, percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "full") });
276
276
  };
277
277
  capacity("disk:array:capacity", "the array", summary.arrayUsedPercent);
278
278
  for (const share of list(storage?.shares).map(dict))
@@ -285,7 +285,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
285
285
  findings.push({ id: `${id}:smart`, area: "disk", severity: "critical", next: "owner", summary: `${text(disk.name)} reports a failed SMART status`, fingerprint: fingerprintOf(`${id}:smart`, "failed") });
286
286
  const temperature = num(disk.temperatureC);
287
287
  if (temperature !== null && temperature >= exports.TEMPERATURE_C)
288
- findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`, String(Math.floor(temperature / 5))) });
288
+ findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`, "hot") });
289
289
  }
290
290
  const parity = dict(disks?.parity);
291
291
  summary.parity = text(parity.result) || null;
@@ -294,7 +294,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
294
294
  if (disks) {
295
295
  const result = summary.parity;
296
296
  if (result === "failed")
297
- findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result", `failed:${num(parity.errors)}`) });
297
+ findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result", "failed") });
298
298
  else if (result !== "in_progress" && (summary.parityAgeDays === null || summary.parityAgeDays >= exports.PARITY_MAX_AGE_DAYS))
299
299
  findings.push({ id: "parity:age", area: "parity", severity: "warn", next: "owner", summary: summary.parityAgeDays === null ? "parity check age is unknown" : `the last parity check was ${summary.parityAgeDays} days ago`, fingerprint: fingerprintOf("parity:age", summary.parityAgeDays === null ? "unknown" : "old") });
300
300
  }
@@ -434,10 +434,12 @@ async function runHouseSweep(deps) {
434
434
  })
435
435
  .sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.id.localeCompare(b.id));
436
436
  if (!deps.replay) {
437
- // A finding that is gone is forgotten, so it is told again if it comes back.
438
- // ...but only when its source was readable: an outage must not make every finding look new on recovery.
437
+ // A finding that has been gone for REMIND_DAYS is forgotten, so it is told again if it comes back after that.
438
+ // A brief disappearance (or an unreadable source) keeps the entry, so a flickering finding never returns as "new".
439
439
  const present = new Set(ordered.map((finding) => finding.id));
440
- const kept = Object.fromEntries(Object.entries(ledger).filter(([id]) => present.has(id) || sourcesFor(id).some((source) => sources[source] !== "ok")));
440
+ const kept = Object.fromEntries(Object.entries(ledger).filter(([id, told]) => present.has(id)
441
+ || sourcesFor(id).some((source) => sources[source] !== "ok")
442
+ || nowMs - Date.parse(told.reportedAt) < exports.REMIND_DAYS * DAY_MS));
441
443
  if (Object.keys(kept).length !== Object.keys(ledger).length)
442
444
  (0, exports.writeLedger)(deps.agentRoot, kept);
443
445
  }
@@ -50,11 +50,12 @@ const externalEventDispositionProperties = {
50
50
  expectedGeneration: { type: "number", description: "Exact generation shown in the external-event turn" },
51
51
  classifiedRevision: { type: "string", description: "Exact observation revision investigated in this turn" },
52
52
  classification: { type: "string", enum: ["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"] },
53
- stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version, or none only for a fresh observation with no applicable policy" },
53
+ stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version when one applies; otherwise use none. Any event may be closed with none." },
54
54
  stewardPolicyKey: { type: "string", description: "Exact current policy key used for this decision" },
55
55
  stewardPolicyVersion: { type: "number", description: "Exact current policy version used for this decision" },
56
56
  decision: { type: "string", enum: ["silent", "act", "ask", "report"] },
57
- reason: { type: "string", description: "Short plain-language reason for the decision" },
57
+ reason: { type: "string", description: "Internal audit note for the decision; never shown to the owner" },
58
+ owner_message: { type: "string", description: "Required for ask or report: the exact short plain message the owner reads, as you would text a friend. No ids, leases, generations, revisions or receipts." },
58
59
  nextWake: { type: "string", enum: ["on_change", "on_escalation", "on_recovery", "at"], description: "Use on_change unless you already called await_condition for this exact event and are supplying its awaitId" },
59
60
  wakeAt: { type: "string", description: "ISO time required only when nextWake=at" },
60
61
  awaitId: { type: "string", description: "Existing await_condition receipt required when nextWake=at; never invent one" },
@@ -70,6 +71,8 @@ const externalEventDispositionBatchSchema = {
70
71
  description: "All exact leases from one coalesced external-event turn",
71
72
  items: { type: "object", properties: externalEventDispositionProperties, required: externalEventDispositionRequired, additionalProperties: false },
72
73
  };
74
+ // The owner reads owner_message verbatim, so internal bookkeeping words never reach them.
75
+ const OWNER_MESSAGE_FORBIDDEN = /\b(generation|lease|revision|receipt|disposition|claimOwner|recordPath)s?\b|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/iu;
73
76
  function prepareExternalEventDisposition(a, ctx) {
74
77
  const agentName = (0, identity_1.getAgentName)();
75
78
  const agentEventRoot = path.resolve((0, router_1.getExternalEventRoot)(), agentName);
@@ -99,6 +102,7 @@ function prepareExternalEventDisposition(a, ctx) {
99
102
  const policyVersion = Number(a.stewardPolicyVersion);
100
103
  const policyKind = String(a.stewardPolicyKind ?? "");
101
104
  const reason = String(a.reason ?? "").trim();
105
+ const ownerText = String(a.owner_message ?? "").trim();
102
106
  if (!reason || (policyKind !== "none" && (!Number.isSafeInteger(policyVersion) || policyVersion < 1))
103
107
  || !["current", "none"].includes(policyKind)
104
108
  || !["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"].includes(classification)
@@ -113,8 +117,6 @@ function prepareExternalEventDisposition(a, ctx) {
113
117
  const policy = (0, steward_policy_1.readStewardPolicy)(agentRoot);
114
118
  let stewardPolicy;
115
119
  if (policyKind === "none") {
116
- if (record.transition !== "opened")
117
- throw new Error("External event no-policy disposition requires a fresh observation");
118
120
  stewardPolicy = { kind: "none" };
119
121
  }
120
122
  else {
@@ -178,15 +180,19 @@ function prepareExternalEventDisposition(a, ctx) {
178
180
  finish: async () => {
179
181
  let ownerMessage;
180
182
  if (decision === "ask" || decision === "report") {
181
- if (Buffer.byteLength(reason, "utf8") > 1_200)
183
+ if (!ownerText)
184
+ throw new Error("ask and report need owner_message: the short plain text the owner will read");
185
+ if (Buffer.byteLength(ownerText, "utf8") > 1_200)
182
186
  throw new Error("External event owner message must be phone-sized");
187
+ if (OWNER_MESSAGE_FORBIDDEN.test(ownerText))
188
+ throw new Error("owner_message must be plain words for the owner, with no internal ids, leases, generations, revisions, receipts or hidden characters");
183
189
  if (!ctx?.externalEventEffects)
184
190
  throw new Error("External event owner delivery is unavailable");
185
191
  // The whole dispatched batch shares one owner message; the delivery port settles later decisions as covered by it.
186
192
  const relatedEvents = [turnContext, ...(turnContext.relatedEvents ?? [])]
187
193
  .filter((event) => path.resolve(event.recordPath) !== recordPath)
188
194
  .map((event) => ({ source: event.source, eventId: event.eventId, generation: event.generation }));
189
- const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: reason, relatedEvents });
195
+ const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: ownerText, relatedEvents });
190
196
  ownerMessage = { status: delivered.status, effectKey: delivered.effectKey };
191
197
  }
192
198
  const handled = (0, router_1.commitExternalEventDisposition)(recordPath, {
@@ -220,7 +226,7 @@ exports.continuityToolDefinitions = [
220
226
  type: "function",
221
227
  function: {
222
228
  name: "external_event_disposition",
223
- description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends reason once for this receipt; silent and act only record the disposition. In a batch, only the first ask or report sends a message and the later ones are recorded as covered by it, so write that first reason to cover every listed lease.",
229
+ description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends owner_message once for this receipt; reason stays internal. silent and act only record the disposition. In a batch, only the first ask or report sends a message and the later ones are recorded as covered by it, so write that first owner_message to cover every listed incident.",
224
230
  parameters: {
225
231
  type: "object",
226
232
  properties: {
@@ -396,7 +402,7 @@ exports.continuityToolDefinitions = [
396
402
  incidentKey: { type: "string", description: "Stable incident key within the source" },
397
403
  classifiedRevision: { type: "string", description: "Observation revision classified by the agent" },
398
404
  correlationKey: { type: "string", description: "Optional key relating several incidents to one concern" },
399
- expectedUpdatedAt: { type: "string", description: "Optional Care updatedAt value for CAS fencing" },
405
+ expectedUpdatedAt: { type: "string", description: "The Care's current updatedAt, copied from the last care_manage result. Required for bind_incident and resolve_incident; optional for update." },
400
406
  currentRisk: { type: "string", description: "Current material risk for this Care, or an empty string to clear it" },
401
407
  nextCheckAt: { type: "string", description: "Exact ISO time of the next owned check" },
402
408
  },
@@ -442,7 +448,7 @@ exports.continuityToolDefinitions = [
442
448
  }
443
449
  else if (a.action === "bind_incident") {
444
450
  if (!a.expectedUpdatedAt)
445
- throw new Error("Care incident binding requires expectedUpdatedAt");
451
+ throw new Error("bind_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
446
452
  result = (0, cares_1.bindCareIncident)(agentRoot, a.id, {
447
453
  source: a.source,
448
454
  incidentKey: a.incidentKey,
@@ -452,7 +458,7 @@ exports.continuityToolDefinitions = [
452
458
  }
453
459
  else if (a.action === "resolve_incident") {
454
460
  if (!a.expectedUpdatedAt)
455
- throw new Error("Care incident resolution requires expectedUpdatedAt");
461
+ throw new Error("resolve_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
456
462
  result = (0, cares_1.resolveCareIncident)(agentRoot, a.id, {
457
463
  source: a.source,
458
464
  incidentKey: a.incidentKey,