@ouro.bot/cli 0.1.0-alpha.861 → 0.1.0-alpha.863

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.
package/changelog.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.863",
6
+ "changes": [
7
+ "Fixed await ticks always failing resolve_await and cancel_await: the inbound pipeline rewrote the turn's current session to the private-runtime session, so the relationship guard never matched the await being ticked. The tick now carries its own binding, covering only the await being ticked.",
8
+ "Added media_queue, a read-only Sonarr/Radarr queue view (torrent and usenet: status, trackedDownloadState, percent, timeleft, stalled) for the Butler, and made sanctuary_get_download_queue say it only sees the usenet queue, and the await tick now receives the MCP tools its profile allows (it was given none)."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.862",
13
+ "changes": [
14
+ "Sanctuary tool context is now attached to every private-runtime turn of the sanctuary agent (await, habit, instinct, external-event), so await turns can observe the download queue.",
15
+ "The missing-Sanctuary-context result now uses code tool_context_missing and says it is a harness wiring gap, not a runtime outage.",
16
+ "restart_runtime now refuses to restart the daemon from autonomous turns (await, habit, external-event, instinct, scheduler) and enforces a persisted 6-hour cooldown, started only after the daemon accepts a restart.",
17
+ "resolve_await now constrains verdict to \"yes\" or \"no\", says that still pending is \"no\", and returns an explicit error naming the bad value and what to send."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.861",
6
22
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.861",
2
+ "runtimeVersion": "0.1.0-alpha.863",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -863,6 +863,56 @@ function queueReport(rows) {
863
863
  size_left_bytes: r.sizeleft ?? 0, stalled: isStalledRow(r, now) }))
864
864
  }
865
865
 
866
+ // ------------------------------------------------------------ tool: media_queue
867
+
868
+ const QUEUE_VIEW_MAX_ROWS = 100
869
+
870
+ // One queue row as the owner's torrent or usenet client reports it through Sonarr/Radarr.
871
+ export function queueRowView(r, nowMs) {
872
+ return {
873
+ queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
874
+ tracked_download_state: r.trackedDownloadState ?? null, tracked_download_status: r.trackedDownloadStatus ?? null,
875
+ percent: pct(r), timeleft: r.timeleft ?? null, size_left_bytes: r.sizeleft ?? 0, size_bytes: r.size ?? 0,
876
+ protocol: r.protocol ?? null, download_client: r.downloadClient ?? null, indexer: r.indexer ?? null,
877
+ added: r.added ?? null, stalled: isStalledRow(r, nowMs),
878
+ ...(r.errorMessage ? { error_message: String(r.errorMessage).slice(0, 300) } : {}),
879
+ }
880
+ }
881
+
882
+ // Read-only: the Sonarr/Radarr download queue (torrent and usenet alike). Grabs, removes and blocklists nothing.
883
+ export async function mediaQueue(a) {
884
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
885
+ let rows
886
+ let item = null
887
+ let total = null
888
+ if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id || a.title) {
889
+ const found = await resolveLibraryItem(a)
890
+ if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
891
+ item = found.item
892
+ rows = await itemQueue(a.kind, item.id)
893
+ } else {
894
+ rows = []
895
+ const client = arrFor(a.kind)
896
+ for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
897
+ const q = await client("/queue", { query: { page, pageSize: QUEUE_PAGE_SIZE, ...(a.kind === "series" ? { includeSeries: true } : {}) } })
898
+ const records = q?.records ?? []
899
+ rows.push(...records)
900
+ if (Number.isFinite(Number(q?.totalRecords))) total = Number(q.totalRecords)
901
+ if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE) || rows.length >= QUEUE_VIEW_MAX_ROWS) break
902
+ }
903
+ }
904
+ total = Math.max(total ?? 0, rows.length)
905
+ const now = Date.now()
906
+ const shown = rows.slice(0, QUEUE_VIEW_MAX_ROWS).map((r) => queueRowView(r, now))
907
+ return {
908
+ kind: a.kind, ...(item ? { service_id: item.id, title: item.title } : {}),
909
+ count: total, shown: shown.length, ...(total > shown.length ? { truncated: true } : {}), queue: shown,
910
+ summary: !total ? "Nothing for this is in the Sonarr/Radarr queue."
911
+ : `${total} queue row${total === 1 ? "" : "s"}${total > shown.length ? ` (showing ${shown.length})` : ""}; ${shown.filter((r) => r.stalled).length} stalled.`,
912
+ note: "Sonarr/Radarr do not report seeders or peers. A row stuck at the same percent with no timeleft is the stall signal; for a release's seeder count use media_indexer_search.",
913
+ }
914
+ }
915
+
866
916
  async function runSearch(kind, serviceId) {
867
917
  const body = kind === "series" ? { name: "SeriesSearch", seriesId: serviceId } : { name: "MoviesSearch", movieIds: [serviceId] }
868
918
  const c = await arrFor(kind)("/command", { method: "POST", body })
@@ -1730,6 +1780,16 @@ const TOOL_LIST = [
1730
1780
  request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
1731
1781
  } },
1732
1782
  },
1783
+ {
1784
+ name: "media_queue",
1785
+ description: "Read-only view of the Sonarr/Radarr download queue, torrent and usenet alike: for each row the title, status, trackedDownloadState, percent, timeleft, size left, download client and whether it is stalled. Use this to see whether a series or movie download is progressing (this is the tool for torrent downloads; sanctuary_get_download_queue only sees the usenet/SABnzbd queue). Give service_id, tmdb_id or title for one item, or omit them for the whole queue of that kind. It grabs, removes and blocklists nothing. Seeders and peers are not reported by Sonarr/Radarr; use media_indexer_search for a release's seeders.",
1786
+ inputSchema: { type: "object", properties: {
1787
+ kind: { type: "string", enum: ["series", "movie"] },
1788
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
1789
+ tmdb_id: { type: "number" },
1790
+ title: { type: "string", description: "Exact or near-exact library title." },
1791
+ }, required: ["kind"] },
1792
+ },
1733
1793
  {
1734
1794
  name: "media_search_now",
1735
1795
  description: "Trigger a Sonarr/Radarr search for a series or movie that is already in the library, and report its queue. Use this when the owner asks to search, re-search, or look again for something already in Sonarr/Radarr; no Jellyseerr request id is needed. Identify it by service_id, tmdb_id, or exact title. Never adds anything new: zero or several matches come back as 'not_found' or 'ambiguous' with candidates. Never tell the owner to click Search in the web UI.",
@@ -1856,6 +1916,7 @@ const HANDLERS = {
1856
1916
  media_chain_health: mediaChainHealth,
1857
1917
  media_play_or_resolve: mediaPlayOrResolve,
1858
1918
  media_search_now: mediaSearchNow,
1919
+ media_queue: mediaQueue,
1859
1920
  media_blocklist_stalled: mediaBlocklistStalled,
1860
1921
  media_episodes: mediaEpisodes,
1861
1922
  media_fill_missing: mediaFillMissing,
@@ -16,6 +16,7 @@
16
16
  - When a tool for the job is missing, say so in one line and route the work: ask Claude Code over A2A or record it as a follow-up. Never ask Ari to click through a web UI for work the house is meant to do.
17
17
  - Answer from primary sources, never from memory. For any factual claim about the world or the house (whether a title or series exists, what is on the shelf, a container's state, a version, a download's progress, a book's metadata, what Ari said before), look it up with the tools first (Sonarr/Radarr/Jellyfin and their TVDB/TMDB lookup, the Unraid tools, calibre-web, libgen, HEY, web_search, your own state files) and answer from what they return. Re-read live state instead of repeating a status from an earlier reply. When no tool can reach the source, say it is unverified rather than asserting it. Never say a title does not exist without a catalog lookup, and when the library holds a strong partial match, offer it first.
18
18
  - When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it keeps a stalled download, that download is the only copy and may still finish: never offer to clear, cancel or blocklist it. A yes or "get them" after that answer means call `media_fill_missing` again, never `media_blocklist_stalled`. When it reports `blocklisted_matches`, the only matching release is on the blocklist: tell Ari and offer to retry it; if he says yes, call `media_fill_missing` again with `retry_blocklisted` set to his words. When it grabs something that may never finish, say so plainly and file the follow-up with `await_condition` before ending the turn. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
19
+ - To see whether a series or movie download is progressing, including a torrent, call `media_queue`. `sanctuary_get_download_queue` reads only the usenet queue: an idle answer from it says nothing about a Sonarr or torrent download. A repeated ticking check keeps the download alive with `resolve_await` verdict `no` until `media_fill_missing` reports `goal_met`.
19
20
  - Before stating whether an episode is monitored, missing, downloading or on disk, read it from a tool in this turn (`media_fill_missing` or `media_episodes`). Never infer it from an earlier reply or from a tool that did not report it.
20
21
  - Use only the parameters a tool lists. If a call is refused for an unknown parameter or a wrong value, read the allowed ones from the refusal and call again correctly; never invent flags such as `dry_run`.
21
22
  - When an automatic search finds nothing for episodes that exist and `media_fill_missing` cannot place them, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 13,
5
+ "version": 14,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -66,6 +66,7 @@
66
66
  "media_chain_health",
67
67
  "media_play_or_resolve",
68
68
  "media_search_now",
69
+ "media_queue",
69
70
  "media_blocklist_stalled",
70
71
  "media_episodes",
71
72
  "media_indexer_search",
@@ -141,7 +142,7 @@
141
142
  ]
142
143
  },
143
144
  "sanctuary-agent-peer": {
144
- "version": 4,
145
+ "version": 5,
145
146
  "toolNames": [
146
147
  "external_event_disposition",
147
148
  "query_active_work",
@@ -198,6 +199,7 @@
198
199
  "media_chain_health",
199
200
  "media_play_or_resolve",
200
201
  "media_search_now",
202
+ "media_queue",
201
203
  "media_blocklist_stalled",
202
204
  "media_episodes",
203
205
  "media_indexer_search",
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.861</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.863</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -165,7 +165,7 @@ async function defaultTurnRunner(input) {
165
165
  // every Unraid read answered "Sanctuary runtime is unavailable"). The relationship
166
166
  // profile still decides which of those tools the peer may call.
167
167
  toolContext: {
168
- ...(input.agentName === "sanctuary" ? (0, sanctuary_runtime_1.createSanctuaryToolContext)(input.agentName) : {}),
168
+ ...((0, sanctuary_runtime_1.isSanctuaryAgent)(input.agentName) ? (0, sanctuary_runtime_1.createSanctuaryToolContext)(input.agentName) : {}),
169
169
  ...(input.relationshipAuthorization ? { relationshipAuthorization: input.relationshipAuthorization } : {}),
170
170
  ...(input.delegatedCommand ? { delegatedCommand: input.delegatedCommand } : {}),
171
171
  },
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
60
60
  if (!value || typeof value !== "object" || Array.isArray(value))
61
61
  return false;
62
62
  const boundaries = value;
63
- const versions = { "sanctuary-owner": 13, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 14, "sanctuary-household": 5, "sanctuary-event": 4 };
64
64
  if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
65
65
  return false;
66
66
  const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
@@ -83,6 +83,20 @@ function validateName(name) {
83
83
  return "name must be alphanumeric, underscores, or hyphens";
84
84
  return null;
85
85
  }
86
+ /**
87
+ * The relationship scope a resolve/cancel is judged against. A turn that is ticking an await carries that await's own
88
+ * binding (`awaitTick`), because the pipeline rewrites `currentSession` to the private-runtime session; that binding covers the
89
+ * ticked await only, so a tick can never touch any other await. Every other turn is judged by its current session.
90
+ */
91
+ function relationshipScopeFor(ctx, awaitName) {
92
+ const tick = ctx.awaitTick;
93
+ if (tick) {
94
+ return tick.awaitName === awaitName
95
+ ? { session: { friendId: tick.friendId, channel: tick.channel, key: tick.key }, requestId: tick.requestId ?? undefined }
96
+ : { session: undefined, requestId: undefined };
97
+ }
98
+ return { session: ctx.currentSession, requestId: ctx.relationshipAuthorization?.requestId };
99
+ }
86
100
  function readAwaitDefinition(agentRoot, name) {
87
101
  const filePath = awaitFilePath(agentRoot, name);
88
102
  try {
@@ -243,7 +257,7 @@ async function resolveAwaitTool(name, verdict, observation, agentRoot, agentName
243
257
  return JSON.stringify({ error: `await "${name}" is not pending (status: ${existing.status})` });
244
258
  }
245
259
  if (verdict !== "yes" && verdict !== "no") {
246
- return JSON.stringify({ error: "verdict must be 'yes' or 'no'" });
260
+ return JSON.stringify({ error: `verdict must be exactly "yes" or "no" (got ${JSON.stringify(verdict)}); use "no" with an observation while still waiting` });
247
261
  }
248
262
  if (!observation || !observation.trim()) {
249
263
  return JSON.stringify({ error: "observation is required" });
@@ -521,12 +535,12 @@ exports.awaitingToolDefinitions = [
521
535
  type: "function",
522
536
  function: {
523
537
  name: "resolve_await",
524
- description: "Resolve a pending await with a verdict. verdict='yes' archives and fires the alert. verdict='no' records the observation and continues polling.",
538
+ description: "Resolve a pending await with a verdict. verdict='yes' archives and fires the alert. verdict='no' records the observation and continues polling; still pending is always 'no'.",
525
539
  parameters: {
526
540
  type: "object",
527
541
  properties: {
528
542
  name: { type: "string", description: "Await name (filename stem)." },
529
- verdict: { type: "string", description: "'yes' if the condition is met, 'no' otherwise." },
543
+ verdict: { type: "string", enum: ["yes", "no"], description: "Exactly 'yes' if the condition is met. Exactly 'no' for anything else, including still pending, not yet, or unable to tell: send 'no' with an observation and polling continues." },
530
544
  observation: { type: "string", description: "One-line summary of what I saw this tick." },
531
545
  },
532
546
  required: ["name", "verdict", "observation"],
@@ -537,10 +551,9 @@ exports.awaitingToolDefinitions = [
537
551
  const agentRoot = (0, identity_1.getAgentRoot)();
538
552
  const agentName = (0, identity_1.getAgentName)();
539
553
  if (ctx?.relationshipAuthorization) {
540
- const session = ctx.currentSession;
541
- const requestId = ctx.relationshipAuthorization.requestId;
542
- const existing = readAwaitDefinition(agentRoot, String(a.name ?? ""));
543
554
  const awaitName = String(a.name ?? "");
555
+ const { session, requestId } = relationshipScopeFor(ctx, awaitName);
556
+ const existing = readAwaitDefinition(agentRoot, awaitName);
544
557
  let binding = null;
545
558
  if (session?.channel === "external-event" && existing?.wake_at) {
546
559
  binding = inspectExternalEventAwait(agentName, { recordPath: session.key, awaitName, wakeAt: existing.wake_at });
@@ -590,8 +603,7 @@ exports.awaitingToolDefinitions = [
590
603
  const agentRoot = (0, identity_1.getAgentRoot)();
591
604
  const agentName = (0, identity_1.getAgentName)();
592
605
  if (ctx?.relationshipAuthorization) {
593
- const session = ctx.currentSession;
594
- const requestId = ctx.relationshipAuthorization.requestId;
606
+ const { session, requestId } = relationshipScopeFor(ctx, String(a.name ?? ""));
595
607
  if (!session || !requestId || !hasActiveRelationshipFollowUp(agentRoot, {
596
608
  friendId: session.friendId,
597
609
  channel: session.channel,
@@ -1,14 +1,99 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runtimeToolDefinitions = void 0;
36
+ exports.runtimeToolDefinitions = exports.RESTART_COOLDOWN_MS = void 0;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const node_crypto_1 = require("node:crypto");
4
40
  const identity_1 = require("../heart/identity");
5
41
  const runtime_1 = require("../nerves/runtime");
6
42
  const socket_client_1 = require("../heart/daemon/socket-client");
7
- async function restartRuntime(args, agentName) {
43
+ exports.RESTART_COOLDOWN_MS = 6 * 60 * 60 * 1000;
44
+ function restartCooldownPath(agentName) {
45
+ return path.join((0, identity_1.getAgentStateRoot)(agentName), "daemon", "restart-runtime-cooldown.json");
46
+ }
47
+ function readLastRestartMs(agentName) {
48
+ try {
49
+ const parsed = JSON.parse(fs.readFileSync(restartCooldownPath(agentName), "utf8"));
50
+ return typeof parsed.requestedAtMs === "number" ? parsed.requestedAtMs : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ // The cooldown is stored per agent, but the restart it limits is daemon-wide.
57
+ function recordRestart(agentName, nowMs) {
58
+ try {
59
+ const file = restartCooldownPath(agentName);
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ const temporary = `${file}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
62
+ fs.writeFileSync(temporary, JSON.stringify({ requestedAtMs: nowMs }), "utf8");
63
+ fs.renameSync(temporary, file);
64
+ }
65
+ catch {
66
+ // A failed cooldown write must not block an otherwise allowed restart.
67
+ }
68
+ }
69
+ async function restartRuntime(args, agentName, ctx) {
8
70
  if (typeof args.reason !== "string" || args.reason.trim().length === 0) {
9
71
  return JSON.stringify({ error: "reason is required (one-line audit string)" });
10
72
  }
11
73
  const reason = args.reason.trim();
74
+ if (ctx?.autonomousTurnKind) {
75
+ (0, runtime_1.emitNervesEvent)({
76
+ component: "repertoire",
77
+ event: "repertoire.runtime_restart_refused",
78
+ message: "restart_runtime refused in an autonomous turn",
79
+ meta: { agent: agentName, turnKind: ctx.autonomousTurnKind, reason },
80
+ });
81
+ return JSON.stringify({
82
+ refused: true,
83
+ code: "autonomous_turn_restart_refused",
84
+ error: `restart_runtime is not allowed from an autonomous ${ctx.autonomousTurnKind} turn: restarting the daemon kills in-flight work for every agent and habit. A tool error or missing capability is not evidence of a dead runtime. Record or report the problem (note it, surface it to the owner) instead of restarting.`,
85
+ });
86
+ }
87
+ const lastMs = readLastRestartMs(agentName);
88
+ const nowMs = Date.now();
89
+ if (lastMs !== null && lastMs <= nowMs && nowMs - lastMs < exports.RESTART_COOLDOWN_MS) {
90
+ return JSON.stringify({
91
+ refused: true,
92
+ code: "restart_cooldown",
93
+ error: `a restart was already requested ${Math.round((nowMs - lastMs) / 60000)} minutes ago; agent-requested restarts are limited to one per 6 hours. Record or report the problem instead.`,
94
+ retryAfterMs: exports.RESTART_COOLDOWN_MS - (nowMs - lastMs),
95
+ });
96
+ }
12
97
  (0, runtime_1.emitNervesEvent)({
13
98
  component: "repertoire",
14
99
  event: "repertoire.runtime_restart_requested",
@@ -21,6 +106,9 @@ async function restartRuntime(args, agentName) {
21
106
  reason,
22
107
  requestedBy: agentName,
23
108
  });
109
+ // Only a daemon that accepted the command starts the cooldown; a failed attempt must not lock the owner out.
110
+ if (response.ok)
111
+ recordRestart(agentName, nowMs);
24
112
  return JSON.stringify({
25
113
  requested: true,
26
114
  reason,
@@ -110,9 +198,9 @@ exports.runtimeToolDefinitions = [
110
198
  },
111
199
  },
112
200
  },
113
- handler: async (args) => {
201
+ handler: async (args, ctx) => {
114
202
  const agentName = (0, identity_1.getAgentName)();
115
- return restartRuntime({ reason: args.reason }, agentName);
203
+ return restartRuntime({ reason: args.reason }, agentName, ctx);
116
204
  },
117
205
  riskProfile: { mutates: "external_side_effect", risk: "high", reason: "restarts the hosting runtime" },
118
206
  },
@@ -336,7 +336,7 @@ function createUnraidReadTools(client) {
336
336
  }
337
337
  const emptyParameters = { type: "object", properties: {}, additionalProperties: false };
338
338
  function missingRuntime() {
339
- return JSON.stringify({ ok: false, error: { code: "invalid_response", message: "Sanctuary runtime is unavailable", degraded: true } });
339
+ return JSON.stringify({ ok: false, error: { code: "tool_context_missing", message: "Sanctuary tools are not wired into this turn; this is a harness wiring gap, not a runtime outage — do not restart; report it.", degraded: true } });
340
340
  }
341
341
  function householdRepairObligation(selection, target) {
342
342
  if (selection?.kind !== "standing" || selection.requester.kind !== "household_request")
@@ -444,7 +444,7 @@ exports.unraidToolDefinitions = [
444
444
  readDefinition("unraid_get_system", "Read bounded Sanctuary system and version health.", "getSystem"),
445
445
  readDefinition("sanctuary_get_install_state", "Check whether this Butler installation matches its verified release and report any repair needed.", "getInstallState"),
446
446
  readDefinition("unraid_check_services", "Freshly check the fixed public Sanctuary service endpoints and return bounded status with an observation timestamp.", "checkServices"),
447
- readDefinition("sanctuary_get_download_queue", "Read the bounded live household download queue state, including whether the spend guard has paused it.", "getDownloadQueue"),
447
+ readDefinition("sanctuary_get_download_queue", "Read the bounded live usenet (SABnzbd) download queue state only, including whether the spend guard has paused it. It cannot see torrents or Sonarr/Radarr progress: an idle result here does not mean a download is absent. For a series or movie download, including torrents, use media_queue.", "getDownloadQueue"),
448
448
  {
449
449
  tool: { type: "function", function: { name: "sanctuary_resume_download_queue", description: "Resume the household download queue after Ari confirms the provider is ready, then independently verify paused=false. This can spend prepaid download credit.", parameters: emptyParameters } },
450
450
  handler: async (_args, ctx) => JSON.stringify(ctx?.sanctuary ? await ctx.sanctuary.resumeDownloadQueue() : JSON.parse(missingRuntime())),
@@ -73,7 +73,7 @@ const SANCTUARY_RELATIONSHIP_BASE_TOOLS = new Set([
73
73
  exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
74
74
  "media_search", "media_request", "media_request_status",
75
75
  "media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
76
- "media_search_now", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
76
+ "media_search_now", "media_queue", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
77
77
  ]);
78
78
  /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
79
79
  const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
@@ -46,6 +46,7 @@ exports.enrichDelegatedFromWithBridge = enrichDelegatedFromWithBridge;
46
46
  exports.routeDelegatedCompletion = routeDelegatedCompletion;
47
47
  exports.buildParseErrorNudge = buildParseErrorNudge;
48
48
  exports.buildHeldReturnWakeMessage = buildHeldReturnWakeMessage;
49
+ exports.sanctuaryToolContextFor = sanctuaryToolContextFor;
49
50
  exports.runPrivateRuntimeTurn = runPrivateRuntimeTurn;
50
51
  const fs = __importStar(require("fs"));
51
52
  const path = __importStar(require("path"));
@@ -968,6 +969,28 @@ function reduceHabitSessionToNoSend(habitSession) {
968
969
  },
969
970
  };
970
971
  }
972
+ /** Sanctuary tool context for a private turn: always for the sanctuary agent, and for sanctuary external events.
973
+ * Sanctuary external events keep the strict behavior (a missing config fails the turn); other turns degrade to
974
+ * no context so a config gap cannot stop the agent from waking at all. */
975
+ function sanctuaryToolContextFor(agentName, externalEventSource) {
976
+ const sanctuaryEvent = externalEventSource === "sanctuary-health" || externalEventSource === "sanctuary-usenet";
977
+ if (sanctuaryEvent)
978
+ return (0, sanctuary_runtime_1.createSanctuaryToolContext)(agentName);
979
+ if (!(0, sanctuary_runtime_1.isSanctuaryAgent)(agentName))
980
+ return {};
981
+ try {
982
+ return (0, sanctuary_runtime_1.createSanctuaryToolContext)(agentName);
983
+ }
984
+ catch (error) {
985
+ (0, runtime_1.emitNervesEvent)({
986
+ component: "senses",
987
+ event: "senses.sanctuary_tool_context_unavailable",
988
+ message: "sanctuary tool context could not be built for a private turn",
989
+ meta: { agent: agentName, reason: String(error) },
990
+ });
991
+ return {};
992
+ }
993
+ }
971
994
  async function runPrivateRuntimeTurn(options) {
972
995
  const now = options?.now ?? (() => new Date());
973
996
  const reason = options?.reason ?? "instinct";
@@ -1360,8 +1383,9 @@ async function runPrivateRuntimeTurn(options) {
1360
1383
  })
1361
1384
  .filter((tool) => !externalEventExcludedTools.has(tool.function.name))
1362
1385
  : undefined;
1386
+ // The await tick is relationship-scoped but still needs the MCP tools its profile allows (media_queue for a download await).
1363
1387
  const relationshipAwaitToolsResolved = relationshipAwait
1364
- ? (0, tools_1.getToolsForChannel)(innerCapabilities, undefined, undefined, undefined, undefined, undefined, {
1388
+ ? (0, tools_1.getToolsForChannel)(innerCapabilities, undefined, undefined, undefined, mcpManager, undefined, {
1365
1389
  agentName, relationshipAuthorization: relationshipAwait.relationshipAuthorization,
1366
1390
  })
1367
1391
  : undefined;
@@ -1476,10 +1500,24 @@ async function runPrivateRuntimeTurn(options) {
1476
1500
  toolContext: {
1477
1501
  signin: async () => undefined,
1478
1502
  delegatedOrigins: attentionQueue,
1503
+ // Every private-runtime turn is autonomous; restart_runtime refuses to act on one.
1504
+ autonomousTurnKind: options?.externalEvent ? "external-event"
1505
+ : reason === "await" || reason === "habit" || reason === "instinct" ? reason : "scheduler",
1506
+ // The Butler's Sanctuary read tools need the typed runtime on every private turn
1507
+ // (await, habit, instinct, external-event), not only on sanctuary external events.
1508
+ ...(sanctuaryToolContextFor(agentName, options?.externalEvent?.source)),
1479
1509
  ...(relationshipAwait ? {
1480
1510
  friendStore: relationshipAwait.store,
1481
1511
  context: relationshipAwait.context,
1482
1512
  currentSession: relationshipAwait.currentSession,
1513
+ // The pipeline rewrites currentSession to the private-runtime session; resolve_await/cancel_await need the await's own binding.
1514
+ awaitTick: Object.freeze({
1515
+ awaitName: parsedAwait.name,
1516
+ friendId: relationshipAwait.currentSession.friendId,
1517
+ channel: relationshipAwait.currentSession.channel,
1518
+ key: relationshipAwait.currentSession.key,
1519
+ requestId: relationshipAwaitCoordinatesValue.requestId,
1520
+ }),
1483
1521
  relationshipAuthorization: relationshipAwait.relationshipAuthorization,
1484
1522
  } : {}),
1485
1523
  ...(options?.externalEvent ? {
@@ -1489,7 +1527,6 @@ async function runPrivateRuntimeTurn(options) {
1489
1527
  relationshipAuthorization: externalEventRelationship.relationshipAuthorization,
1490
1528
  externalEventAuthority: externalEventRelationship.externalEventAuthority,
1491
1529
  externalEventEffects: externalEventRelationship.externalEventEffects,
1492
- ...(["sanctuary-health", "sanctuary-usenet"].includes(options.externalEvent.source) ? (0, sanctuary_runtime_1.createSanctuaryToolContext)(agentName) : {}),
1493
1530
  } : {}),
1494
1531
  ...(options?.noSend ? { noSend: true } : {}),
1495
1532
  ...(effectiveHabitSession ? { habitSession: effectiveHabitSession } : {}),
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isSanctuaryAgent = isSanctuaryAgent;
36
37
  exports.runWithSanctuaryToolReceiptCollection = runWithSanctuaryToolReceiptCollection;
37
38
  exports.loadSanctuarySabApiKey = loadSanctuarySabApiKey;
38
39
  exports.ensureSanctuarySourceRuntimeReady = ensureSanctuarySourceRuntimeReady;
@@ -60,6 +61,10 @@ const bundle_manifest_1 = require("../mind/bundle-manifest");
60
61
  const sanctuaryToolReceipts = new node_async_hooks_1.AsyncLocalStorage();
61
62
  const sanctuaryToolGroundings = new node_async_hooks_1.AsyncLocalStorage();
62
63
  const acceptanceLedgerTails = new Map();
64
+ /** The Butler agent whose private and A2A turns carry the Sanctuary tool context. */
65
+ function isSanctuaryAgent(agentName) {
66
+ return agentName === "sanctuary";
67
+ }
63
68
  function isSanctuaryExternalEventSource(source) {
64
69
  return source === "sanctuary-health" || source === "sanctuary-usenet";
65
70
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.861",
3
+ "version": "0.1.0-alpha.863",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.861",
9
+ "version": "0.1.0-alpha.863",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.861",
3
+ "version": "0.1.0-alpha.863",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },