@ouro.bot/cli 0.1.0-alpha.934 → 0.1.0-alpha.936

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 (40) hide show
  1. package/changelog.json +18 -0
  2. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  3. package/deploy/unraid/sanctuary.xml +1 -1
  4. package/dist/heart/daemon/cli-defaults.js +5 -0
  5. package/dist/heart/daemon/cli-exec.js +9 -0
  6. package/dist/heart/daemon/cli-help.js +2 -2
  7. package/dist/heart/daemon/cli-parse.js +2 -1
  8. package/dist/heart/daemon/daemon.js +14 -0
  9. package/dist/heart/daemon/ouro-entry.js +4 -1
  10. package/dist/heart/daemon/runtime-logging.js +1 -1
  11. package/dist/heart/daemon/sense-manager.js +53 -0
  12. package/dist/heart/mailbox/mailbox-http-hooks.js +1 -0
  13. package/dist/heart/mailbox/mailbox-http-routes.js +8 -0
  14. package/dist/heart/mailbox/mailbox-read.js +3 -1
  15. package/dist/heart/mailbox/mailbox-supervision.js +131 -0
  16. package/dist/heart/shepherd/custody-types.js +2 -0
  17. package/dist/heart/shepherd/mandates.js +352 -0
  18. package/dist/heart/shepherd/operation-commands.js +19 -0
  19. package/dist/heart/shepherd/operations.js +135 -0
  20. package/dist/heart/shepherd/supervision.js +114 -0
  21. package/dist/heart/shepherd/types.js +2 -0
  22. package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
  23. package/dist/mailbox-ui/assets/index-xU5krmwW.js +1 -0
  24. package/dist/mailbox-ui/index.html +2 -2
  25. package/dist/nerves/coverage/file-completeness.js +4 -0
  26. package/dist/repertoire/tools-shell.js +9 -4
  27. package/dist/senses/shepherd/checkin-store.js +209 -41
  28. package/dist/senses/shepherd/cmux.js +69 -23
  29. package/dist/senses/shepherd/guidance.js +5 -2
  30. package/dist/senses/shepherd/herdr.js +84 -2
  31. package/dist/senses/shepherd/observation-publisher.js +320 -0
  32. package/dist/senses/shepherd/operations-defaults.js +86 -0
  33. package/dist/senses/shepherd/operations.js +159 -0
  34. package/dist/senses/shepherd/recovery.js +543 -0
  35. package/dist/senses/shepherd/sense.js +8 -0
  36. package/dist/senses/teams.js +13 -1
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
  39. package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +0 -1
  40. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +0 -1
@@ -36,7 +36,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.refuseCustody = refuseCustody;
40
+ exports.validCustodyInputReceipt = validCustodyInputReceipt;
41
+ exports.copyCustodyInputReceipt = copyCustodyInputReceipt;
42
+ exports.parseCustodyDispatch = parseCustodyDispatch;
43
+ exports.validateCustodyDirectory = validateCustodyDirectory;
39
44
  exports.checkInStorePath = checkInStorePath;
45
+ exports.assertNoCustodyMaintenance = assertNoCustodyMaintenance;
46
+ exports.custodyCapacity = custodyCapacity;
47
+ exports.custodyRevision = custodyRevision;
48
+ exports.advanceCustodyRevision = advanceCustodyRevision;
49
+ exports.hasRetiredCustodyInstance = hasRetiredCustodyInstance;
50
+ exports.retiredCustodyKey = retiredCustodyKey;
51
+ exports.readRetiredCustodyRecord = readRetiredCustodyRecord;
52
+ exports.openCustodyDatabase = openCustodyDatabase;
40
53
  exports.openCheckInStore = openCheckInStore;
41
54
  const node_crypto_1 = require("node:crypto");
42
55
  const fs = __importStar(require("node:fs"));
@@ -45,17 +58,20 @@ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
45
58
  const runtime_1 = require("../../nerves/runtime");
46
59
  const returns_1 = require("./returns");
47
60
  const MAX_ROW = 4096;
61
+ const COMPLETION_HEADROOM = 2300;
48
62
  const MAX_DB = 64 * 1024 * 1024;
49
63
  const WARN_DB = 48 * 1024 * 1024;
64
+ const warnedDatabases = new WeakSet();
50
65
  const bytes = (value) => Buffer.byteLength(JSON.stringify(value));
51
66
  const text = (value) => typeof value === "string" && value.length > 0;
52
67
  const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
53
68
  const timestamp = (value) => text(value) && Number.isFinite(Date.parse(value));
54
- function refuse(reason) {
69
+ function refuseCustody(reason) {
55
70
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_store_refused",
56
71
  message: "check-in custody operation refused", meta: { reason } });
57
72
  throw new Error(`Shepherd check-in custody: ${reason}`);
58
73
  }
74
+ const refuse = refuseCustody;
59
75
  function bounded(value, limit, field) {
60
76
  if (!text(value) || bytes(value) > limit)
61
77
  refuse(`${field} byte limit or type`);
@@ -72,9 +88,24 @@ function dispatchId(input) {
72
88
  function validClaim(value) {
73
89
  return object(value) && validInput(value) && text(value.owner) && bytes(value.owner) <= 256 &&
74
90
  ["claimed", "sent", "not_sent", "unknown"].includes(String(value.state)) &&
75
- (value.reason === undefined || text(value.reason) && bytes(value.reason) <= 1024);
91
+ (value.reason === undefined || text(value.reason) && bytes(value.reason) <= 1024)
92
+ && (value.receipt === undefined || validCustodyInputReceipt(value.receipt)
93
+ && value.receipt.requestId === value.id && value.receipt.surfaceId === value.session && value.receipt.processInstance === value.instance
94
+ && receiptMatchesOutcome(value.state, value.receipt));
76
95
  }
77
- function parseDispatch(row) {
96
+ function validCustodyInputReceipt(value) {
97
+ return object(value) && ["requestId", "surfaceId", "workspaceId", "processInstance"].every(key => text(value[key]) && bytes(value[key]) <= 256)
98
+ && typeof value.pasteAdmitted === "boolean" && typeof value.submitAdmitted === "boolean"
99
+ && (!value.submitAdmitted || value.pasteAdmitted);
100
+ }
101
+ function copyCustodyInputReceipt(receipt) {
102
+ const { requestId, surfaceId, workspaceId, processInstance, pasteAdmitted, submitAdmitted } = receipt;
103
+ return { requestId, surfaceId, workspaceId, processInstance, pasteAdmitted, submitAdmitted };
104
+ }
105
+ function receiptMatchesOutcome(state, receipt) {
106
+ return state === "sent" ? receipt.pasteAdmitted && receipt.submitAdmitted : state !== "not_sent" || !receipt.pasteAdmitted && !receipt.submitAdmitted;
107
+ }
108
+ function parseCustodyDispatch(row) {
78
109
  if (Buffer.byteLength(row.data) > MAX_ROW)
79
110
  refuse("record byte limit");
80
111
  const value = JSON.parse(row.data);
@@ -83,71 +114,196 @@ function parseDispatch(row) {
83
114
  refuse("invalid dispatch schema");
84
115
  return value;
85
116
  }
86
- function privateDirectory(directory) {
87
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
117
+ const parseDispatch = parseCustodyDispatch;
118
+ function validateCustodyDirectory(directory, create) {
119
+ const uid = process.getuid();
120
+ for (let candidate = directory; candidate !== path.dirname(candidate); candidate = path.dirname(candidate)) {
121
+ const stat = fs.lstatSync(candidate, { throwIfNoEntry: false });
122
+ if (stat?.isSymbolicLink())
123
+ refuse("private directory ancestor is a link");
124
+ if (stat && (!stat.isDirectory() || stat.uid !== 0 && stat.uid !== uid
125
+ || (stat.mode & 0o022) !== 0 && !(stat.uid === 0 && (stat.mode & 0o1000) !== 0)))
126
+ refuse("private directory ancestor ownership or mode");
127
+ }
128
+ if (create)
129
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
130
+ if (!fs.lstatSync(directory, { throwIfNoEntry: false }))
131
+ return false;
88
132
  const stat = fs.lstatSync(directory);
89
- if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid())
90
- refuse("private directory ownership or mode");
133
+ if (!stat.isDirectory() || (stat.mode & 0o7777) !== 0o700 || stat.uid !== uid)
134
+ refuse("private directory ownership or mode; require 0700");
135
+ return true;
91
136
  }
92
137
  function privateFile(file) {
93
138
  const stat = fs.lstatSync(file);
94
- if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid())
139
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o7777) !== 0o600 || stat.uid !== process.getuid())
95
140
  refuse("private file ownership or mode");
96
141
  }
97
142
  function checkInStorePath(agentRoot) {
98
143
  return path.join((0, returns_1.shepherdStateDir)(agentRoot), "checkins", "custody.sqlite");
99
144
  }
100
- function openCheckInStore(file) {
145
+ function assertNoCustodyMaintenance(file) {
146
+ if (fs.lstatSync(`${file}.maintenance.json`, { throwIfNoEntry: false }))
147
+ refuse("interrupted or active maintenance; resume the exact owner maintenance request");
148
+ }
149
+ function custodyCapacity(database, writing = false) {
150
+ const size = Number(database.pragma("page_count", { simple: true })) * Number(database.pragma("page_size", { simple: true }));
151
+ if (size > MAX_DB || writing && size + 65536 > MAX_DB)
152
+ refuse("database capacity; no input authorized");
153
+ if (!warnedDatabases.has(database) && size >= WARN_DB) {
154
+ warnedDatabases.add(database);
155
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_capacity_warning",
156
+ message: "check-in custody is approaching capacity", meta: { bytes: size, limit: MAX_DB } });
157
+ }
158
+ return { bytes: size, capacity: size + 65536 > MAX_DB ? "refused" : size >= WARN_DB ? "warning" : "normal" };
159
+ }
160
+ function custodyRevision(database) {
161
+ const row = database.prepare("SELECT revision FROM custody_meta WHERE singleton=1").get();
162
+ if (!row || typeof row.revision !== "number" || !Number.isSafeInteger(row.revision) || row.revision < 0)
163
+ refuse("invalid custody revision schema");
164
+ return row.revision;
165
+ }
166
+ function advanceCustodyRevision(database) {
167
+ const revision = custodyRevision(database) + 1;
168
+ if (!Number.isSafeInteger(revision))
169
+ refuse("custody revision capacity");
170
+ database.prepare("UPDATE custody_meta SET revision=? WHERE singleton=1").run(revision);
171
+ return revision;
172
+ }
173
+ function hasRetiredCustodyInstance(database, input) {
174
+ const row = database.prepare(`SELECT key,host,session,instance,data FROM retired_instances WHERE host=? AND session=?
175
+ AND (instance IS NULL OR ? IS NULL OR instance=?) LIMIT 1`).get(input.host, input.session, input.instance, input.instance);
176
+ if (!row)
177
+ return false;
178
+ validateRetiredRow(row);
179
+ return true;
180
+ }
181
+ function retiredCustodyKey(input) {
182
+ return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([input.host, input.session, input.instance])).digest("hex");
183
+ }
184
+ function readRetiredCustodyRecord(database, input) {
185
+ const row = database.prepare("SELECT key,host,session,instance,data FROM retired_instances WHERE key=?").get(retiredCustodyKey(input));
186
+ return row ? validateRetiredRow(row) : null;
187
+ }
188
+ function validateRetiredRow(row) {
189
+ if (Buffer.byteLength(row.data) > MAX_ROW)
190
+ refuse("retired instance index capacity");
191
+ const data = JSON.parse(row.data);
192
+ const expectedKey = retiredCustodyKey(row);
193
+ if (!object(data) || !object(data.proof) || row.key !== expectedKey || data.proof.kind !== "host_process_inventory"
194
+ || data.proof.host !== row.host || data.proof.session !== row.session || data.proof.instance !== row.instance
195
+ || data.proof.gone !== true || !timestamp(data.proof.checkedAt) || typeof data.proof.wholeSessionRetired !== "boolean"
196
+ || row.instance === null && !data.proof.wholeSessionRetired)
197
+ refuse("invalid retired instance schema");
198
+ if (data.archive !== undefined && (!object(data.archive) || typeof data.archive.path !== "string" || !path.isAbsolute(data.archive.path)
199
+ || typeof data.archive.digest !== "string" || !/^[a-f0-9]{64}$/.test(data.archive.digest)))
200
+ refuse("invalid retired archive reference schema");
201
+ return data;
202
+ }
203
+ function openCustodyDatabase(file, mode) {
204
+ const readonly = mode === "read" || mode === "maintenance-read";
101
205
  if (!path.isAbsolute(file) || path.normalize(file) !== file)
102
206
  refuse("database path");
103
- privateDirectory(path.dirname(file));
104
- try {
105
- const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
106
- fs.closeSync(fd);
107
- }
108
- catch (error) {
109
- if (error.code !== "EEXIST")
110
- throw error;
207
+ if (mode !== "maintenance" && mode !== "maintenance-read")
208
+ assertNoCustodyMaintenance(file);
209
+ if (!validateCustodyDirectory(path.dirname(file), !readonly))
210
+ return null;
211
+ if (readonly) {
212
+ if (!fs.lstatSync(file, { throwIfNoEntry: false }))
213
+ return null;
111
214
  }
112
- privateFile(file);
113
- const database = new better_sqlite3_1.default(file, { fileMustExist: true });
114
- let warned = false;
115
- function capacity(writing = false) {
116
- const size = Number(database.pragma("page_count", { simple: true })) * Number(database.pragma("page_size", { simple: true }));
117
- if (size > MAX_DB || writing && size + 65536 > MAX_DB)
118
- refuse("database capacity; no input authorized");
119
- if (!warned && size >= WARN_DB) {
120
- warned = true;
121
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_checkin_capacity_warning",
122
- message: "check-in custody is approaching capacity", meta: { bytes: size, limit: MAX_DB } });
215
+ else {
216
+ try {
217
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
218
+ fs.closeSync(fd);
219
+ }
220
+ catch (error) {
221
+ if (error.code !== "EEXIST")
222
+ throw error;
123
223
  }
124
224
  }
225
+ privateFile(file);
226
+ for (const suffix of ["-journal", "-wal", "-shm"])
227
+ if (fs.lstatSync(`${file}${suffix}`, { throwIfNoEntry: false }))
228
+ privateFile(`${file}${suffix}`);
229
+ const database = new better_sqlite3_1.default(file, { fileMustExist: true, readonly });
125
230
  try {
126
231
  const version = database.pragma("user_version", { simple: true });
127
- if (version !== 0 && version !== 1)
232
+ if (![0, 1, 2].includes(Number(version)) || version === 0 && readonly)
128
233
  refuse("unsupported database schema");
129
- capacity();
130
- database.pragma("journal_mode = DELETE");
234
+ custodyCapacity(database);
131
235
  database.pragma("busy_timeout = 1000");
236
+ if (version !== 0) {
237
+ database.prepare("SELECT id,host,session,instance,data FROM dispatches LIMIT 0").all();
238
+ database.prepare("SELECT signature,data FROM notices LIMIT 0").all();
239
+ }
240
+ if (readonly) {
241
+ if (version === 2)
242
+ validateCustodyV2(database);
243
+ return database;
244
+ }
245
+ database.pragma("journal_mode = DELETE");
246
+ database.pragma("synchronous = FULL");
132
247
  database.pragma(`max_page_count = ${Math.floor(MAX_DB / Number(database.pragma("page_size", { simple: true })))}`);
133
- database.exec(`CREATE TABLE IF NOT EXISTS dispatches (
134
- id TEXT PRIMARY KEY NOT NULL, host TEXT NOT NULL, session TEXT NOT NULL, instance TEXT, data TEXT NOT NULL
135
- );
136
- CREATE INDEX IF NOT EXISTS dispatch_barriers ON dispatches(host,session,instance);
137
- CREATE TABLE IF NOT EXISTS notices (signature TEXT PRIMARY KEY NOT NULL, data TEXT NOT NULL);`);
138
- database.pragma("user_version = 1");
248
+ if (version !== 2)
249
+ database.transaction(() => {
250
+ if (database.pragma("user_version", { simple: true }) === 2)
251
+ return;
252
+ database.exec(`CREATE TABLE IF NOT EXISTS dispatches (
253
+ id TEXT PRIMARY KEY NOT NULL, host TEXT NOT NULL, session TEXT NOT NULL, instance TEXT, data TEXT NOT NULL
254
+ );
255
+ CREATE INDEX IF NOT EXISTS dispatch_barriers ON dispatches(host,session,instance);
256
+ CREATE TABLE IF NOT EXISTS notices (signature TEXT PRIMARY KEY NOT NULL, data TEXT NOT NULL);
257
+ CREATE TABLE custody_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
258
+ INSERT INTO custody_meta VALUES(1,0);
259
+ CREATE TABLE custody_requests (actor_id TEXT NOT NULL,request_id TEXT NOT NULL,hash TEXT NOT NULL,receipt TEXT NOT NULL,PRIMARY KEY(actor_id,request_id));
260
+ CREATE TABLE reconciliations (id TEXT PRIMARY KEY,dispatch_id TEXT NOT NULL,data TEXT NOT NULL);
261
+ CREATE INDEX reconciliation_dispatch ON reconciliations(dispatch_id);
262
+ CREATE TABLE retired_instances (key TEXT PRIMARY KEY,host TEXT NOT NULL,session TEXT NOT NULL,instance TEXT,data TEXT NOT NULL);
263
+ CREATE INDEX retired_scope ON retired_instances(host,session,instance);
264
+ CREATE TABLE custody_migrations (version INTEGER PRIMARY KEY,data TEXT NOT NULL);`);
265
+ for (const row of database.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
266
+ parseDispatch(row);
267
+ const counts = database.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
268
+ database.prepare("INSERT INTO custody_migrations VALUES(2,?)").run(JSON.stringify({ fromVersion: version, at: new Date().toISOString(), preservedDispatches: counts.count }));
269
+ database.pragma("user_version = 2");
270
+ }).immediate();
271
+ validateCustodyV2(database);
272
+ for (const row of database.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
273
+ parseDispatch(row);
139
274
  }
140
275
  catch (error) {
141
276
  database.close();
142
277
  throw error;
143
278
  }
144
- const transact = (work) => database.transaction(() => { capacity(true); return work(); }).immediate();
279
+ return database;
280
+ }
281
+ function validateCustodyV2(database) {
282
+ custodyRevision(database);
283
+ for (const query of [
284
+ "SELECT actor_id,request_id,hash,receipt FROM custody_requests LIMIT 0",
285
+ "SELECT id,dispatch_id,data FROM reconciliations LIMIT 0",
286
+ "SELECT key,host,session,instance,data FROM retired_instances LIMIT 0",
287
+ "SELECT version,data FROM custody_migrations LIMIT 0",
288
+ ])
289
+ database.prepare(query).all();
290
+ for (const row of database.prepare("SELECT key,host,session,instance,data FROM retired_instances").iterate())
291
+ validateRetiredRow(row);
292
+ }
293
+ function openCheckInStore(file) {
294
+ const database = openCustodyDatabase(file, "write");
295
+ const transact = (work) => database.transaction(() => {
296
+ assertNoCustodyMaintenance(file);
297
+ custodyCapacity(database, true);
298
+ return work();
299
+ }).immediate();
145
300
  function write(record) {
146
301
  const data = JSON.stringify(record);
147
302
  if (Buffer.byteLength(data) > MAX_ROW)
148
303
  refuse("record byte limit");
149
304
  database.prepare(`INSERT INTO dispatches(id,host,session,instance,data) VALUES(?,?,?,?,?)
150
305
  ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(record.id, record.host, record.session, record.instance, data);
306
+ advanceCustodyRevision(database);
151
307
  }
152
308
  function notice(signature) {
153
309
  bounded(signature, MAX_ROW - 128, "signature");
@@ -169,9 +325,11 @@ function openCheckInStore(file) {
169
325
  refuse("invalid dispatch input schema");
170
326
  bounded(owner, 256, "owner");
171
327
  const record = { ...input, id: dispatchId(input), owner, state: "claimed" };
172
- if (bytes(record) + 1050 > MAX_ROW)
328
+ if (bytes(record) + COMPLETION_HEADROOM > MAX_ROW)
173
329
  refuse("reserved record byte limit");
174
330
  return transact(() => {
331
+ if (hasRetiredCustodyInstance(database, input))
332
+ return null;
175
333
  const rows = database.prepare(`SELECT id,host,session,instance,data FROM dispatches
176
334
  WHERE id=? OR (host=? AND session=? AND (instance IS NULL OR ? IS NULL OR instance=?))`)
177
335
  .all(record.id, input.host, input.session, input.instance, input.instance);
@@ -190,6 +348,10 @@ function openCheckInStore(file) {
190
348
  refuse("invalid completion state");
191
349
  if (result.reason !== undefined)
192
350
  bounded(result.reason, 1024, "reason");
351
+ if (result.receipt !== undefined && !validCustodyInputReceipt(result.receipt))
352
+ refuse("invalid host receipt schema");
353
+ if (result.receipt && !receiptMatchesOutcome(result.state, result.receipt))
354
+ refuse("host receipt contradicts completion outcome");
193
355
  transact(() => {
194
356
  const row = database.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").get(id);
195
357
  if (!row)
@@ -197,7 +359,11 @@ function openCheckInStore(file) {
197
359
  const prior = parseDispatch(row);
198
360
  if (prior.owner !== owner || prior.state !== "claimed")
199
361
  refuse("dispatch owner or phase mismatch");
200
- write({ ...prior, state: result.state, ...(result.reason === undefined ? {} : { reason: result.reason }) });
362
+ if (result.receipt && (result.receipt.requestId !== id || result.receipt.surfaceId !== prior.session
363
+ || result.receipt.processInstance !== prior.instance))
364
+ refuse("host receipt identity mismatch");
365
+ write({ ...prior, state: result.state, ...(result.reason === undefined ? {} : { reason: result.reason }),
366
+ ...(result.receipt === undefined ? {} : { receipt: copyCustodyInputReceipt(result.receipt) }) });
201
367
  });
202
368
  },
203
369
  shouldNotify(signature) { return notice(signature) === null; },
@@ -209,8 +375,10 @@ function openCheckInStore(file) {
209
375
  if (Buffer.byteLength(data) > MAX_ROW)
210
376
  refuse("notice byte limit");
211
377
  transact(() => {
212
- if (!notice(signature))
378
+ if (!notice(signature)) {
213
379
  database.prepare("INSERT INTO notices(signature,data) VALUES(?,?)").run(signature, data);
380
+ advanceCustodyRevision(database);
381
+ }
214
382
  });
215
383
  },
216
384
  close() { database.close(); },
@@ -132,31 +132,46 @@ function createCmuxHost(client, options = {}) {
132
132
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_status_error", message: "could not clear a Shepherd status", meta: { error: error.message } });
133
133
  });
134
134
  };
135
- async function surfaceEntries() {
135
+ async function surfaceEntries(strict = false) {
136
136
  const tree = await client.call("system.tree", { all_windows: true });
137
- return list(tree.windows).flatMap((window) => list(window.workspaces).map((workspace) => ({ window, workspace }))).flatMap(({ window, workspace }) => list(workspace.panes).flatMap((pane) => list(pane.surfaces)).map((surface) => ({
138
- id: str(surface.id) ?? "",
139
- ref: str(surface.ref) ?? "",
140
- workspace: str(workspace.ref),
141
- workspaceTitle: str(workspace.title),
142
- workspaceId: str(workspace.id) ?? "",
143
- title: str(surface.title) ?? "",
144
- tty: str(surface.tty),
145
- type: str(surface.type) ?? "",
146
- agent: hooked.get(str(surface.id) ?? "")?.agent ?? null,
147
- cwd: hooked.get(str(surface.id) ?? "")?.cwd ?? null,
148
- // The human's terminal: the focused surface of the selected workspace in a visible window.
149
- focused: surface.focused === true && workspace.selected === true && window.visible !== false,
150
- ...(surface.focused === true && workspace.selected === true && window.visible !== false && window.key === true
151
- ? { selected: true } : {}),
152
- })));
137
+ const records = (value) => {
138
+ if (strict && (!Array.isArray(value) || !value.every(isRecord)))
139
+ throw new Error("invalid cmux terminal inventory");
140
+ return list(value);
141
+ };
142
+ const seen = new Set();
143
+ return records(tree.windows).flatMap((window) => records(window.workspaces).map((workspace) => ({ window, workspace }))).flatMap(({ window, workspace }) => records(workspace.panes).flatMap((pane) => records(pane.surfaces)).map((surface) => {
144
+ if (strict && (typeof surface.id !== "string" || !surface.id.trim() || typeof surface.type !== "string"
145
+ || !["terminal", "browser"].includes(surface.type) || seen.has(surface.id)))
146
+ throw new Error("invalid cmux terminal inventory identity");
147
+ seen.add(str(surface.id) ?? "");
148
+ return {
149
+ id: str(surface.id) ?? "",
150
+ ref: str(surface.ref) ?? "",
151
+ workspace: str(workspace.ref),
152
+ workspaceTitle: str(workspace.title),
153
+ workspaceId: str(workspace.id) ?? "",
154
+ title: str(surface.title) ?? "",
155
+ tty: str(surface.tty),
156
+ type: str(surface.type) ?? "",
157
+ agent: hooked.get(str(surface.id) ?? "")?.agent ?? null,
158
+ cwd: hooked.get(str(surface.id) ?? "")?.cwd ?? null,
159
+ // The human's terminal: the focused surface of the selected workspace in a visible window.
160
+ focused: surface.focused === true && workspace.selected === true && window.visible !== false,
161
+ ...(surface.focused === true && workspace.selected === true && window.visible !== false && window.key === true
162
+ ? { selected: true } : {}),
163
+ };
164
+ }));
165
+ }
166
+ async function retirementInventory() {
167
+ return (await surfaceEntries(true)).filter(surface => surface.type === "terminal").map(surface => surface.id);
153
168
  }
154
- async function surfaces(freshFocus = false) {
155
- const entries = await surfaceEntries();
169
+ async function surfaces(freshFocus = false, strictInventory = false) {
170
+ const entries = await surfaceEntries(strictInventory);
156
171
  const identities = await (options.identifyTerminals ?? terminal_process_1.identifyTerminalProcesses)(entries.filter((entry) => entry.type === "terminal" && entry.tty).map((entry) => entry.tty));
157
172
  // Process lookup is awaited. On inspection paths, do not return focus from
158
173
  // the tree sampled before that await. A moved/rebound TTY loses authority.
159
- const current = freshFocus ? await surfaceEntries() : entries;
174
+ const current = freshFocus ? await surfaceEntries(strictInventory) : entries;
160
175
  return current.map((entry) => {
161
176
  const bound = entries.some(before => before.id === entry.id && before.tty === entry.tty && before.workspaceId === entry.workspaceId && before.type === entry.type);
162
177
  const identity = entry.tty && bound ? identities.get(entry.tty.replace(/^\/dev\//, "")) : undefined;
@@ -216,6 +231,7 @@ function createCmuxHost(client, options = {}) {
216
231
  async list() {
217
232
  return (await surfaces()).filter((surface) => surface.type === "terminal").map(({ workspaceId: _workspaceId, type: _type, ...session }) => session);
218
233
  },
234
+ retirementInventory,
219
235
  read,
220
236
  inspect,
221
237
  async promptIfUnchanged(sessionId, line, expected, canSend) {
@@ -390,16 +406,40 @@ function createCmuxHost(client, options = {}) {
390
406
  },
391
407
  });
392
408
  };
393
- /** Terminals whose agent is not idle: output that changed and then held still for the quiet window is a return. */
409
+ let lastObserved = [];
410
+ /** Inventory reads stay outside the judge queue; idle-hook terminals are observed but never rejudged here. */
394
411
  const poll = async () => {
395
412
  try {
396
- const terminals = (await surfaces()).filter((surface) => surface.type === "terminal" && hooked.get(surface.id)?.idle !== true);
413
+ const terminals = (await surfaces(false, !!handlers.observed)).filter((surface) => surface.type === "terminal");
414
+ if (closed)
415
+ return;
397
416
  const seen = new Set(terminals.map((surface) => surface.id));
398
417
  for (const id of screens.keys())
399
418
  if (!seen.has(id))
400
419
  screens.delete(id);
420
+ const rows = [];
401
421
  for (const surface of terminals) {
402
- const hash = (0, node_crypto_1.createHash)("sha256").update(await read(surface.id, POLL_LINES)).digest("hex");
422
+ const idle = hooked.get(surface.id)?.idle === true;
423
+ if (idle)
424
+ screens.delete(surface.id);
425
+ if (idle && !handlers.observed)
426
+ continue;
427
+ let text;
428
+ try {
429
+ text = await read(surface.id, POLL_LINES);
430
+ }
431
+ catch (error) {
432
+ rows.push({ session: surface, activity: "unknown", observedAt: null, error: "read_failed" });
433
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_poll_error",
434
+ message: "could not read one cmux terminal; other terminals remain observable", meta: { error: (0, redact_1.redactSecrets)(String(error)) } });
435
+ continue;
436
+ }
437
+ if (closed)
438
+ return;
439
+ rows.push({ session: surface, activity: surface.agent ? (0, prompt_state_1.inspectCodingPrompt)(surface.agent, text).activity : "unknown", observedAt: now() });
440
+ if (idle)
441
+ continue;
442
+ const hash = (0, node_crypto_1.createHash)("sha256").update(text).digest("hex");
403
443
  const last = screens.get(surface.id);
404
444
  const at = now();
405
445
  if (!last)
@@ -413,9 +453,15 @@ function createCmuxHost(client, options = {}) {
413
453
  handlers.returned({ sessionId: surface.id, transitionId: `cmux:quiet:${surface.id}:${last.changedAt}`, agent: surface.agent, cwd: surface.cwd ?? null, title: surface.title, workspaceTitle: surface.workspaceTitle, lastBody: null, backgroundWork: null });
414
454
  }
415
455
  }
456
+ if (closed)
457
+ return;
458
+ lastObserved = rows;
459
+ handlers.observed?.({ at: now(), sessions: rows });
416
460
  }
417
461
  catch (error) {
418
462
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_cmux_poll_error", message: "could not poll cmux terminals; the next poll retries", meta: { error: error.message } });
463
+ if (!closed)
464
+ handlers.observed?.({ at: now(), sessions: lastObserved, error: "host_unavailable" });
419
465
  }
420
466
  if (!closed)
421
467
  cancelPoll = schedule(() => void poll(), exports.POLL_MS);
@@ -145,8 +145,8 @@ function bounded(value, name, max) {
145
145
  function knownTarget(root, target, live, record) {
146
146
  if (!validTarget(target))
147
147
  throw new Error("invalid Shepherd session target");
148
- const known = record ?? [...(0, returns_1.readReturns)(root).filter(validReturn)].reverse().find(r => r.host === target.host && r.session === target.session)
149
- ?? live.find(t => t.host === target.host && t.session === target.session);
148
+ const known = record ?? live.find(t => t.host === target.host && t.session === target.session)
149
+ ?? [...(0, returns_1.readReturns)(root).filter(validReturn)].reverse().find(r => r.host === target.host && r.session === target.session);
150
150
  if (!known)
151
151
  throw new Error("unknown session; no recorded or verified live Shepherd target");
152
152
  if (known.host !== target.host || known.session !== target.session || known.cwd !== target.cwd || known.task !== target.task)
@@ -191,6 +191,9 @@ async function mutate(root, op, input, apply) {
191
191
  throw new Error("request ID was already used with a different body");
192
192
  return prior.receipt;
193
193
  }
194
+ if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || input.expectedRevision !== state.revision)) {
195
+ throw new Error("Shepherd guidance revision conflict");
196
+ }
194
197
  const clean = { actorId: input.actorId, requestId: input.requestId, at: new Date().toISOString(), kind: "operator instruction", returnId: null };
195
198
  const result = apply(state, clean);
196
199
  state.revision++;