@ouro.bot/cli 0.1.0-alpha.932 → 0.1.0-alpha.933

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,15 @@
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.933",
6
+ "changes": [
7
+ "Recover existing supervised Teams tunnels after permanent rejection or sustained readiness loss, with scoped and confirmed bot endpoint registration.",
8
+ "Recover accepted Shepherd notice intent without importing audit history, and compact pending observations into bounded historical session digests with durable receipt custody.",
9
+ "Preserve valid premature judgments independently of invalid suggested replies; keep length-only periodic fixed-text continuation behind all existing input safety guards.",
10
+ "Correlate Teams request and reply stages without message bodies, and contain notice-ledger failures so later returns remain runnable."
11
+ ]
12
+ },
4
13
  {
5
14
  "version": "0.1.0-alpha.932",
6
15
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.932",
2
+ "runtimeVersion": "0.1.0-alpha.933",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.932</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.933</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -25,11 +25,11 @@ const CALLBACKS = { onModelStart: NOOP, onModelStreamStart: NOOP, onTextChunk: N
25
25
  function replyTextProblem(text) {
26
26
  const line = text.replace(/`/g, "").replace(/\s+/g, " ").trim();
27
27
  if (!line)
28
- return { problem: "an empty reply" };
29
- if (line.length > exports.REPLY_MAX_CHARS)
30
- return { problem: `a reply of ${line.length} characters (the limit is ${exports.REPLY_MAX_CHARS})` };
28
+ return { problem: "an empty reply", issue: "missing" };
31
29
  if (/[\\@;$|<>&\p{Cc}\p{Cf}]/u.test(line) || /^[/!#]/.test(line))
32
- return { problem: "a reply with characters a terminal could read as a command" };
30
+ return { problem: "a reply with characters a terminal could read as a command", issue: "unsafe" };
31
+ if (line.length > exports.REPLY_MAX_CHARS)
32
+ return { problem: `a reply of ${line.length} characters (the limit is ${exports.REPLY_MAX_CHARS})`, issue: "too_long" };
33
33
  return { line };
34
34
  }
35
35
  function safeReplyText(text) {
@@ -81,8 +81,10 @@ function parseVerdict(content) {
81
81
  return { kind: "unclear", reply: null, reason: `premature, but the key came from JSON inside prose: ${reason}` };
82
82
  return { kind, reply: { key: reply.key }, reason };
83
83
  }
84
- const checked = typeof reply.text === "string" ? replyTextProblem(reply.text) : { problem: "no reply" };
85
- return "line" in checked ? { kind, reply: { text: checked.line }, reason } : { kind: "unclear", reply: null, reason: `premature, but the judge gave ${checked.problem}: ${reason}` };
84
+ const checked = Object.hasOwn(reply, "key") ? { problem: "an unsupported key", issue: "unsafe" }
85
+ : typeof reply.text === "string" ? replyTextProblem(reply.text) : { problem: "no reply", issue: "missing" };
86
+ return "line" in checked ? { kind, reply: { text: checked.line }, reason }
87
+ : { kind: parsed.inProse ? "unclear" : kind, reply: null, replyIssue: checked.issue, reason: `premature, but the judge gave ${checked.problem}: ${reason}` };
86
88
  }
87
89
  function judgeMessages(packet) {
88
90
  return [
@@ -47,7 +47,6 @@ const friends_1 = require("@ouro.bot/friends");
47
47
  const await_alert_1 = require("../../heart/awaiting/await-alert");
48
48
  const config_1 = require("../../heart/config");
49
49
  const runtime_1 = require("../../nerves/runtime");
50
- const returns_1 = require("./returns");
51
50
  const notice_outbox_1 = require("./notice-outbox");
52
51
  /**
53
52
  * The owner notice: after a judged return, the agent's owner hears what happened in one short plain message on their
@@ -55,12 +54,23 @@ const notice_outbox_1 = require("./notice-outbox");
55
54
  * policy covers whether or not the Ouro agent decides it matters. Returns that arrive close together share one message.
56
55
  */
57
56
  exports.NOTICE_COALESCE_MS = 5_000;
58
- /** Returns named one by one in a coalesced message; the rest are counted. */
57
+ /** Distinct sessions named in a digest; the remaining sessions are counted. */
59
58
  exports.NOTICE_LIST_MAX = 6;
60
59
  exports.NOTICE_RETRY_MS = 30_000;
61
60
  const NAME_MAX = 60;
62
61
  const REASON_MAX = 300;
63
62
  const REPLY_MAX = 240;
63
+ const BLOCK_DESCRIPTION = {
64
+ reply_invalid: "the suggested reply was invalid",
65
+ menu_disallowed: "scheduled continuation cannot approve a menu",
66
+ host_unverified: "the host could not verify safe input state",
67
+ guidance: "operator guidance blocked input",
68
+ loop_guard: "the reply limit was reached",
69
+ focus: "the human owns the focused session",
70
+ state_changed: "the target state changed",
71
+ custody: "input delivery was already claimed or needs reconciliation",
72
+ stopped: "Shepherd stopped",
73
+ };
64
74
  /** Kinds a "needs-me" owner still hears about. */
65
75
  const NEEDS_ME = new Set(["gate", "done", "loop_guard"]);
66
76
  function noticeWanted(policy, record) {
@@ -103,6 +113,8 @@ function noticeLine(record, location) {
103
113
  const sent = record.reply.startsWith("key:") ? `pressed ${plain(record.reply.slice(4), 10)}` : `sent it back: "${plain(record.reply, REPLY_MAX)}"`;
104
114
  return `${subject} stopped early, so I ${sent}. ${why}`;
105
115
  }
116
+ if (record.actionBlockReason)
117
+ return `${subject} stopped early; no input sent because ${BLOCK_DESCRIPTION[record.actionBlockReason]}. ${why}`;
106
118
  return `${subject} stopped early, but I did not answer it, so it is waiting for you. ${why}`;
107
119
  case "gate":
108
120
  return `${subject} needs you. ${why}`;
@@ -120,13 +132,30 @@ function noticeLine(record, location) {
120
132
  return `${subject} stopped but I could not judge it, so I left it. ${why}`;
121
133
  }
122
134
  }
123
- /** One message for a burst: a single return is its line; several are a short list, the extra ones counted. */
124
- function noticeMessage(records, refs = new Map()) {
125
- const lines = records.slice(0, exports.NOTICE_LIST_MAX).map((record) => noticeLine(record, refs.get(record.session)));
126
- if (records.length === 1)
127
- return lines[0];
128
- const more = records.length - lines.length;
129
- return [`${records.length} coding agents stopped:`, ...lines.map((line) => `- ${line}`), ...(more > 0 ? [`- and ${more} more.`] : [])].join("\n");
135
+ /** A bounded display of latest observations; all input records retain delivery custody. */
136
+ function noticeMessage(records, refs = new Map(), context = {}) {
137
+ const latest = new Map();
138
+ for (const record of records) {
139
+ const key = JSON.stringify([record.host, record.session]);
140
+ const previous = latest.get(key);
141
+ const at = Date.parse(record.at);
142
+ const previousAt = previous ? Date.parse(previous.at) : NaN;
143
+ if (!previous || (Number.isFinite(at) && (!Number.isFinite(previousAt) || at > previousAt))
144
+ || ((!Number.isFinite(at) && !Number.isFinite(previousAt) || at === previousAt) && record.transition > previous.transition))
145
+ latest.set(key, record);
146
+ }
147
+ const sessions = [...latest].sort(([a], [b]) => a < b ? -1 : 1).map(([, record]) => record);
148
+ const lines = sessions.slice(0, exports.NOTICE_LIST_MAX).map(record => {
149
+ const line = noticeLine(record, refs.get(record.session));
150
+ if (!context.recovered)
151
+ return line;
152
+ const at = Date.parse(record.at);
153
+ return `${line} (${Number.isFinite(at) ? `observed at ${new Date(at).toISOString()}` : "observation time unknown"})`;
154
+ });
155
+ const more = sessions.length - lines.length;
156
+ const body = sessions.length === 1 ? lines[0]
157
+ : [`Updates from ${sessions.length} coding sessions:`, ...lines.map(line => `- ${line}`), ...(more > 0 ? [`- and ${more} more.`] : [])].join("\n");
158
+ return context.recovered ? `Includes recovered historical observations, not live status:\n${body}` : body;
130
159
  }
131
160
  function teamsAadId(friend) {
132
161
  const ext = friend.externalIds.find((entry) => entry.provider === "aad" && !entry.externalId.startsWith("group:"));
@@ -192,30 +221,43 @@ function createTeamsNoticeSender(agentRoot, deps = {}) {
192
221
  return sendProactiveTeamsMessageToSession({ friendId: target.friendId, sessionKey: target.key, text, textFormat: "plain", intent: "generic_outreach", captureReceipt: true }, { botApi: await bot, store: new friends_1.FileFriendStore(path.join(agentRoot, "friends")) });
193
222
  };
194
223
  }
195
- /** Collects notices for NOTICE_COALESCE_MS after the first, then sends them as one message. It never throws. */
224
+ /** Collects durable notice intents; acceptance is distinct from transport delivery. */
196
225
  function createOwnerNotifier(options) {
197
226
  const { agent, policy } = options;
198
227
  const resolveTargets = options.resolveTargets ?? (() => resolveNoticeTargets(options.agentRoot));
199
228
  const send = options.send ?? createTeamsNoticeSender(options.agentRoot);
200
229
  const retryWrites = new Map();
230
+ const recoveredNoticeIds = new Set();
201
231
  const writes = new Set();
202
232
  let cancel = null;
203
233
  let stopped = false;
204
234
  let flushing = null;
235
+ let drainRequested = false;
205
236
  function schedule(ms) {
206
237
  if (!stopped && policy !== "off")
207
- cancel ??= options.schedule(() => { void flush(); }, ms);
238
+ cancel ??= options.schedule(() => {
239
+ cancel?.();
240
+ cancel = null;
241
+ if (flushing)
242
+ drainRequested = true;
243
+ else
244
+ void flush();
245
+ }, ms);
208
246
  }
209
247
  function persistIntent(record) {
210
248
  const key = `${record.host}:${record.session}:${record.transition}:${record.at}`;
211
249
  retryWrites.set(key, record);
212
- const write = (0, notice_outbox_1.enqueueNotice)(options.agentRoot, record).then(() => {
250
+ const write = (0, notice_outbox_1.enqueueNotice)(options.agentRoot, record).then(id => {
213
251
  retryWrites.delete(key);
252
+ return id;
214
253
  }).catch(error => {
215
254
  (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "could not persist a Shepherd owner notice intent; it remains waiting", meta: { agent, error: error.message } });
216
255
  schedule(exports.NOTICE_RETRY_MS);
217
- }).finally(() => { writes.delete(write); });
256
+ throw error;
257
+ });
218
258
  writes.add(write);
259
+ // Observe cleanup immediately, while the producer still receives the rejecting promise.
260
+ void write.then(() => { writes.delete(write); }, () => { writes.delete(write); });
219
261
  return write;
220
262
  }
221
263
  async function runFlush() {
@@ -223,10 +265,11 @@ function createOwnerNotifier(options) {
223
265
  cancel = null;
224
266
  if (stopped || policy === "off")
225
267
  return;
226
- await Promise.all([...writes]);
227
- for (const record of [...retryWrites.values()])
228
- await persistIntent(record);
229
268
  try {
269
+ await Promise.allSettled([...writes]);
270
+ const retried = await Promise.allSettled([...retryWrites.values()].map(persistIntent));
271
+ if (retried.some(result => result.status === "rejected"))
272
+ schedule(exports.NOTICE_RETRY_MS);
230
273
  const entries = (0, notice_outbox_1.readNoticeOutbox)(options.agentRoot).filter(entry => noticeWanted(policy, entry.record) && (Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state !== "sent")));
231
274
  if (entries.length === 0)
232
275
  return;
@@ -239,6 +282,8 @@ function createOwnerNotifier(options) {
239
282
  // Without the host's handles, each notice names a short session id instead.
240
283
  }
241
284
  const targets = await resolveTargets();
285
+ if (stopped)
286
+ return;
242
287
  if (targets.length === 0) {
243
288
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_no_target", message: "no family friend is reachable on Teams, so the Shepherd owner notice was not sent", meta: { agent, returns: entries.length } });
244
289
  schedule(exports.NOTICE_RETRY_MS);
@@ -248,15 +293,21 @@ function createOwnerNotifier(options) {
248
293
  const reachable = new Set(targets.map(target => target.friendId));
249
294
  let retry = entries.some(entry => Object.entries(entry.deliveries).some(([friendId, delivery]) => delivery.state === "pending" && !reachable.has(friendId)));
250
295
  for (const target of targets) {
296
+ if (stopped)
297
+ break;
251
298
  const pending = entries.filter(entry => (0, notice_outbox_1.noticeDeliveryState)(entry, target.friendId) === "pending");
252
- for (let start = 0; start < pending.length && !stopped; start += exports.NOTICE_LIST_MAX) {
299
+ if (pending.length > 0) {
253
300
  // Only the worker that atomically claims a pending entry may initiate transport.
254
- const batch = await (0, notice_outbox_1.claimPendingNotices)(options.agentRoot, pending.slice(start, start + exports.NOTICE_LIST_MAX).map(entry => entry.id), target.friendId, new Date().toISOString());
301
+ const batch = await (0, notice_outbox_1.claimPendingNotices)(options.agentRoot, pending.map(entry => entry.id), target.friendId, new Date().toISOString());
255
302
  if (batch.length === 0)
256
303
  continue;
257
304
  const ids = batch.map(entry => entry.id);
305
+ if (stopped) {
306
+ await (0, notice_outbox_1.recordNoticeDelivery)(options.agentRoot, ids, target.friendId, { state: "pending", at: new Date().toISOString(), reason: "stopped before transport" });
307
+ break;
308
+ }
258
309
  try {
259
- const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs));
310
+ const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs, { recovered: ids.some(id => recoveredNoticeIds.has(id)) }));
260
311
  const state = result.delivered ? "sent"
261
312
  : result.delivery ? result.delivery.state === "not_sent" ? "pending" : "unknown"
262
313
  : result.reason && result.reason !== "send_error" ? "pending" : "unknown";
@@ -288,19 +339,20 @@ function createOwnerNotifier(options) {
288
339
  function flush() {
289
340
  if (flushing)
290
341
  return flushing;
291
- flushing = runFlush().finally(() => { flushing = null; });
342
+ flushing = runFlush().finally(() => {
343
+ flushing = null;
344
+ if (drainRequested) {
345
+ drainRequested = false;
346
+ schedule(exports.NOTICE_COALESCE_MS);
347
+ }
348
+ });
292
349
  return flushing;
293
350
  }
294
351
  try {
295
352
  const existing = (0, notice_outbox_1.readNoticeOutbox)(options.agentRoot);
296
- let recovered = false;
297
- for (const record of (0, returns_1.readReturns)(options.agentRoot)) {
298
- if (record.noticeEligible === true && noticeWanted(policy, record) && !(0, notice_outbox_1.knownNotice)(options.agentRoot, (0, notice_outbox_1.noticeRecordId)(record), existing)) {
299
- void persistIntent(record);
300
- recovered = true;
301
- }
302
- }
303
- if (recovered || existing.some(entry => Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state === "pending")))
353
+ for (const entry of existing)
354
+ recoveredNoticeIds.add(entry.id);
355
+ if (existing.some(entry => Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state === "pending")))
304
356
  schedule(exports.NOTICE_RETRY_MS);
305
357
  }
306
358
  catch (error) {
@@ -309,11 +361,13 @@ function createOwnerNotifier(options) {
309
361
  }
310
362
  return {
311
363
  notify(record) {
312
- if (stopped || !noticeWanted(policy, record))
313
- return Promise.resolve();
364
+ if (stopped)
365
+ return Promise.resolve({ state: "suppressed", reason: "stopped" });
366
+ if (!noticeWanted(policy, record))
367
+ return Promise.resolve({ state: "suppressed", reason: "policy" });
314
368
  const write = persistIntent(record);
315
369
  schedule(exports.NOTICE_COALESCE_MS);
316
- return write;
370
+ return write.then(noticeId => ({ state: "queued", noticeId }));
317
371
  },
318
372
  flush,
319
373
  stop() {
@@ -368,6 +368,7 @@ async function startShepherdSenseApp(options) {
368
368
  guidanceRevision: String(record.guidanceRevision), reply: "key" in reply ? `key:${reply.key}` : reply.text,
369
369
  trigger: record.trigger, transition: record.transition, at: record.at }, dispatchOwner);
370
370
  if (!claim) {
371
+ record.actionBlockReason = "custody";
371
372
  record.reason = `durable dispatch custody refused duplicate or unresolved input: ${record.reason}`;
372
373
  return false;
373
374
  }
@@ -385,6 +386,7 @@ async function startShepherdSenseApp(options) {
385
386
  }
386
387
  }
387
388
  catch (error) {
389
+ record.actionBlockReason = "custody";
388
390
  record.dispatch.state = "unknown";
389
391
  try {
390
392
  custody.completeDispatch(claim.id, dispatchOwner, { state: "unknown", reason: field((0, redact_1.redactSecrets)(String(error)), 200) });
@@ -399,6 +401,7 @@ async function startShepherdSenseApp(options) {
399
401
  ...(result.reason ? { reason: field((0, redact_1.redactSecrets)(result.reason), 200) } : {}) });
400
402
  record.dispatch.state = result.state;
401
403
  if (result.state === "not_sent") {
404
+ record.actionBlockReason = "state_changed";
402
405
  record.reason = `guarded host refused before input: ${result.reason ?? "target changed"}; ${record.reason}`;
403
406
  return false;
404
407
  }
@@ -420,18 +423,26 @@ async function startShepherdSenseApp(options) {
420
423
  /** False for a return nothing new was decided about: the loop guard still holds, or the same background wait. */
421
424
  let notice = true;
422
425
  let noticeSignature = null;
426
+ let observationSignature = null;
427
+ let noticeBasis = null;
428
+ let status = null;
429
+ let quietWorkingStatus = false;
430
+ let escalateOutcome = false;
431
+ let wakeOutcome = false;
423
432
  let judged = false;
424
433
  try {
425
434
  let current;
426
435
  if (periodic) {
427
436
  notice = false;
428
437
  if (periodic.generation !== generationFor(event.sessionId) || now() - periodic.observedAt < cmux_1.QUIET_MS) {
438
+ record.actionBlockReason = "state_changed";
429
439
  record.reason = "the check-in observation was invalidated or has not been quiet long enough";
430
440
  return;
431
441
  }
432
442
  current = await host.inspect(event.sessionId);
433
443
  if (stopped || periodic.generation !== generationFor(event.sessionId) ||
434
444
  inspectionFingerprint(current) !== inspectionFingerprint(periodic.first) || current.session.focused) {
445
+ record.actionBlockReason = stopped ? "stopped" : current.session.focused ? "focus" : "state_changed";
435
446
  record.reason = "the check-in target changed during the quiet interval";
436
447
  return;
437
448
  }
@@ -440,6 +451,7 @@ async function startShepherdSenseApp(options) {
440
451
  if (state.tripped) {
441
452
  // Tripped: no judge call and no new escalation until the human looks at the session.
442
453
  Object.assign(record, { kind: "loop_guard", reason: `Shepherd stopped answering this session until ${human} focuses it` });
454
+ record.actionBlockReason = "loop_guard";
443
455
  notice = false;
444
456
  return;
445
457
  }
@@ -456,11 +468,21 @@ async function startShepherdSenseApp(options) {
456
468
  record.task = [...text.matchAll(/Desk-Task:\s*(\S+)/g)].pop()?.[1] ?? null;
457
469
  const screen = (0, redact_1.screenTail)(text, exports.SCREEN_LINES);
458
470
  if (recovery && screen !== recovery.screen) {
471
+ record.actionBlockReason = "state_changed";
459
472
  record.reason = "the screen changed since the network stop, so recovery was not sent";
460
473
  return;
461
474
  }
462
475
  const target = { host: host.name, session: event.sessionId, cwd: record.cwd, task: record.task };
463
- const guidance = guidanceFor(target);
476
+ const checkedGuidance = () => {
477
+ try {
478
+ return guidanceFor(target);
479
+ }
480
+ catch (error) {
481
+ record.actionBlockReason = "guidance";
482
+ throw error;
483
+ }
484
+ };
485
+ const guidance = checkedGuidance();
464
486
  record.guidanceRevision = guidance.revision;
465
487
  let verdict;
466
488
  try {
@@ -478,117 +500,172 @@ async function startShepherdSenseApp(options) {
478
500
  }
479
501
  judged = true;
480
502
  if (periodic && (stopped || periodic.generation !== generationFor(event.sessionId))) {
503
+ record.actionBlockReason = stopped ? "stopped" : "state_changed";
481
504
  record.reason = "the check-in was invalidated during judgment; no input or notice sent";
482
505
  return;
483
506
  }
484
507
  // The host's own word that background work is running outranks a reading of the screen: such a stop is never a return.
485
508
  const kind = (current ? current.backgroundWork : event.backgroundWork) ? "working" : verdict.kind;
486
509
  const news = verdict.news === true || (kind === "working" && verdict.kind === "gate");
487
- Object.assign(record, { kind, reason: verdict.reason, latencyMs: verdict.latencyMs, inputTokens: verdict.inputTokens });
510
+ Object.assign(record, { kind, judgeKind: verdict.kind, reason: verdict.reason, latencyMs: verdict.latencyMs, inputTokens: verdict.inputTokens,
511
+ ...(verdict.replyIssue ? { replyIssue: verdict.replyIssue } : {}) });
488
512
  if (!recovery && !periodic && kind === "waiting" && verdict.waitingOnNetwork)
489
513
  rememberNetworkWait(event, screen);
490
- noticeSignature = fingerprint([host.name, event.sessionId, record.agent, record.cwd, record.task, screen, guidance.revision, kind]);
491
- notice = !periodic || custody.shouldNotify(noticeSignature);
514
+ const noticeScreen = screen.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\b/g, "<observation-time>");
515
+ noticeBasis = [host.name, event.sessionId, record.agent, record.cwd, record.task, noticeScreen, guidance.revision];
516
+ notice = true;
492
517
  // A terminal that keeps going quiet without needing anything is checked less often.
493
518
  if (!periodic)
494
519
  host.pace?.(event.sessionId, kind === "working" || kind === "unclear");
495
520
  state.answers = state.answers.filter((at) => now() - at < 3_600_000);
496
521
  if (kind === "working") {
497
522
  // Still working: a progress update, never an answer. It reaches the Ouro agent only when it carries news, and wakes nothing.
498
- if (news && notice)
499
- escalate(escalationContent(record, human));
523
+ if (news)
524
+ escalateOutcome = true;
500
525
  else
501
526
  notice = false;
502
- if (!periodic || notice)
503
- await host.signal(event.sessionId, field(`${record.agent ?? "agent"} waiting on its background work: ${verdict.reason}`, 120));
527
+ quietWorkingStatus = !periodic && !news;
528
+ status = `${record.agent ?? "agent"} waiting on its background work: ${verdict.reason}`;
504
529
  }
505
- if (kind === "premature" && verdict.reply) {
506
- if ((recovery || periodic) && "key" in verdict.reply) {
530
+ const actionableReply = verdict.reply ?? (periodic && verdict.replyIssue === "too_long" ? { text: exports.PERIODIC_RESUME } : null);
531
+ if (kind === "premature" && !actionableReply)
532
+ record.actionBlockReason = "reply_invalid";
533
+ if (kind === "premature" && actionableReply) {
534
+ if ((recovery || periodic) && "key" in actionableReply) {
535
+ record.actionBlockReason = "menu_disallowed";
507
536
  record.reason = `${periodic ? "periodic check-in" : "connectivity recovery"} never presses approval or menu keys: ${verdict.reason}`;
508
537
  }
509
538
  else if (periodic && (!current.instance || current.activity !== "idle" || current.composer !== "empty" ||
510
539
  current.backgroundWork !== false || !current.session.agent || !host.promptIfUnchanged)) {
540
+ record.actionBlockReason = "host_unverified";
511
541
  record.reason = `the host cannot verify an idle, empty, supported agent process for guarded input: ${verdict.reason}`;
512
542
  }
513
543
  else if (guidance.paused) {
544
+ record.actionBlockReason = "guidance";
514
545
  record.reason = `Shepherd guidance has this session paused, so nothing was sent: ${verdict.reason}`;
515
546
  }
516
547
  else if (state.streak >= exports.STREAK_MAX || state.answers.length >= exports.HOURLY_MAX) {
517
548
  Object.assign(record, { kind: "loop_guard", reason: `loop guard: ${verdict.reason}` });
549
+ record.actionBlockReason = "loop_guard";
518
550
  state.tripped = true;
519
551
  }
520
552
  else {
521
553
  const sessions = await host.list();
522
554
  const session = sessions.find(session => session.id === event.sessionId);
523
555
  if (now() - state.focusedAt < exports.FOCUS_HOLD_MS || session?.focused) {
556
+ record.actionBlockReason = "focus";
524
557
  // Never type into the session the human is in, or one they just left.
525
558
  record.reason = `${human} is in this session or just left it, so it is theirs: ${verdict.reason}`;
526
559
  }
527
560
  else if (periodic && (!session || session.agent !== current.session.agent || session.cwd !== current.session.cwd)) {
561
+ record.actionBlockReason = "state_changed";
528
562
  record.reason = `the check-in target changed while judging, so nothing was sent: ${verdict.reason}`;
529
563
  }
530
564
  else if (!periodic && (0, redact_1.withoutNumbers)((0, redact_1.screenTail)((0, redact_1.redactSecrets)(await host.read(event.sessionId, exports.READ_LINES)), exports.SCREEN_LINES)) !== (0, redact_1.withoutNumbers)(screen)) {
565
+ record.actionBlockReason = "state_changed";
531
566
  record.reason = `the screen changed while judging, so nothing was sent: ${verdict.reason}`;
532
567
  }
533
568
  else {
534
569
  const finalInspection = periodic ? await host.inspect(event.sessionId) : undefined;
535
570
  if (periodic && (stopped || periodic.generation !== generationFor(event.sessionId) ||
536
571
  finalInspection.session.focused || inspectionFingerprint(finalInspection) !== inspectionFingerprint(current))) {
572
+ record.actionBlockReason = stopped ? "stopped" : finalInspection.session.focused ? "focus" : "state_changed";
537
573
  record.reason = `the check-in changed while judging, so nothing was sent: ${verdict.reason}`;
538
574
  return;
539
575
  }
540
576
  // No awaited host reads between this final check and initiating the send.
541
- const latest = guidanceFor(target);
577
+ const latest = checkedGuidance();
542
578
  if (stopped || (recovery && (latestReturns.get(event.sessionId) !== event || now() - state.focusedAt < exports.FOCUS_HOLD_MS)) ||
543
579
  periodic && (periodic.generation !== generationFor(event.sessionId) || now() - state.focusedAt < exports.FOCUS_HOLD_MS)) {
580
+ record.actionBlockReason = stopped ? "stopped" : now() - state.focusedAt < exports.FOCUS_HOLD_MS ? "focus" : "state_changed";
544
581
  record.reason = `Shepherd stopped, the human focused this session, or it returned again while judging, so nothing was sent: ${verdict.reason}`;
545
582
  }
546
583
  else if (latest.paused || latest.revision !== guidance.revision) {
584
+ record.actionBlockReason = "guidance";
547
585
  record.reason = `Shepherd guidance paused or changed while judging (revision ${guidance.revision} to ${latest.revision}), so nothing was sent: ${verdict.reason}`;
548
586
  }
549
587
  else {
550
- const reply = periodic ? { text: exports.PERIODIC_RESUME } : recovery ? { text: NETWORK_RESUME } : verdict.reply;
588
+ const reply = periodic ? { text: exports.PERIODIC_RESUME } : recovery ? { text: NETWORK_RESUME } : actionableReply;
551
589
  const canSend = periodic ? () => {
552
590
  if (stopped || periodic.generation !== generationFor(event.sessionId) || now() - state.focusedAt < exports.FOCUS_HOLD_MS)
553
591
  return false;
554
- const live = guidanceFor(target);
592
+ const live = checkedGuidance();
555
593
  return !live.paused && live.revision === guidance.revision;
556
594
  } : undefined;
557
595
  if (await dispatch(record, reply, current?.instance ?? session?.instance ?? null, fingerprint(screen), periodic ? current : undefined, canSend)) {
558
596
  Object.assign(record, { action: "respond", reply: "key" in reply ? `key:${reply.key}` : reply.text });
559
597
  state.streak += 1;
560
598
  state.answers.push(now());
561
- if (!periodic || notice)
562
- await host.signal(event.sessionId, field(`answered for ${human}: ${verdict.reason}`, 120));
599
+ status = `answered for ${human}: ${verdict.reason}`;
563
600
  }
564
601
  }
565
602
  }
566
603
  }
567
604
  }
568
- if ((!periodic || notice) && (record.kind === "gate" || record.kind === "done" || record.kind === "waiting" || record.kind === "loop_guard")) {
569
- escalate(escalationContent(record, human));
570
- requestWake(record.transition);
571
- await host.signal(event.sessionId, field(`${record.kind === "gate" ? `needs ${human}` : record.kind === "done" ? "done" : record.kind === "waiting" ? "waiting on an outside dependency" : "stopped answering"}: ${verdict.reason}`, 120));
605
+ if (record.kind === "gate" || record.kind === "done" || record.kind === "waiting" || record.kind === "loop_guard") {
606
+ escalateOutcome = true;
607
+ wakeOutcome = true;
608
+ status = `${record.kind === "gate" ? `needs ${human}` : record.kind === "done" ? "done" : record.kind === "waiting" ? "waiting on an outside dependency" : "stopped answering"}: ${verdict.reason}`;
572
609
  }
573
610
  }
574
611
  catch (error) {
575
612
  Object.assign(record, { kind: record.reason ? record.kind : "error", reason: `${record.reason ? `${record.reason}; then ` : ""}${error.message}` });
576
613
  }
577
614
  finally {
615
+ if (noticeBasis && notice) {
616
+ observationSignature = fingerprint([...noticeBasis, record.kind, record.actionBlockReason === "custody" ? null : record.actionBlockReason ?? null]);
617
+ noticeSignature = record.action === "respond" ? fingerprint([observationSignature, "respond", record.reply])
618
+ : record.dispatch?.state === "unknown" ? fingerprint([observationSignature, "unknown"]) : observationSignature;
619
+ try {
620
+ notice = custody.shouldNotify(noticeSignature);
621
+ }
622
+ catch (error) {
623
+ notice = false;
624
+ record.noticeState = "failed";
625
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "could not compare owner-notice suppression; no new delivery intent accepted", meta: { agent, reason: "signature_read_failed", error: error.message } });
626
+ }
627
+ }
628
+ try {
629
+ if (notice && escalateOutcome) {
630
+ escalate(escalationContent(record, human));
631
+ if (wakeOutcome)
632
+ requestWake(record.transition);
633
+ }
634
+ if (status && (notice || quietWorkingStatus))
635
+ await host.signal(event.sessionId, field(status, 120));
636
+ }
637
+ catch (error) {
638
+ record.reason += `; outcome signaling failed: ${error.message}`;
639
+ }
578
640
  record.noticeEligible = notice;
579
- log(record);
580
- if (judged || !periodic)
581
- history.set(event.sessionId, record);
582
641
  if (notice) {
583
642
  try {
584
- await notifyOwner(record);
585
- if (noticeSignature)
586
- custody.markNotified(noticeSignature, record.at);
643
+ const acceptance = await notifyOwner(record);
644
+ record.noticeState = acceptance.state;
645
+ if (acceptance.state === "queued")
646
+ record.noticeId = acceptance.noticeId;
647
+ else
648
+ record.noticeSuppressionReason = acceptance.reason;
649
+ if (noticeSignature && (acceptance.state === "queued" || acceptance.reason === "policy")) {
650
+ try {
651
+ custody.markNotified(noticeSignature, record.at);
652
+ // A sent/uncertain effect also settles the unchanged observation, not just that action outcome.
653
+ if (observationSignature && observationSignature !== noticeSignature)
654
+ custody.markNotified(observationSignature, record.at);
655
+ }
656
+ catch (error) {
657
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "owner notice was accepted but its suppression signature could not be recorded", meta: { agent, error: error.message } });
658
+ }
659
+ }
587
660
  }
588
661
  catch (error) {
589
- (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "owner notice custody failed; the eligible return remains logged for recovery", meta: { agent, error: error.message } });
662
+ record.noticeState = "failed";
663
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "owner notice custody failed; the observation is not accepted delivery intent", meta: { agent, error: error.message } });
590
664
  }
591
665
  }
666
+ log(record);
667
+ if (judged || !periodic)
668
+ history.set(event.sessionId, record);
592
669
  }
593
670
  }
594
671
  function guidanceFor(target) {
@@ -694,6 +694,14 @@ function getFriendStore() {
694
694
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
695
695
  return new friends_1.FileFriendStore(friendsPath);
696
696
  }
697
+ function reportTeamsRequest(stage, conversationId, context, details = {}) {
698
+ if (!context?.inboundActivityId)
699
+ return;
700
+ (0, runtime_1.emitNervesEvent)({
701
+ component: "channels", event: "channel.teams_request_stage", message: "Teams conversational request stage",
702
+ meta: { stage, inboundActivityId: context.inboundActivityId, conversationId, tenantId: context.tenantId, botId: context.botApi?.id, ...details },
703
+ });
704
+ }
697
705
  function createTeamsCommandRegistry() {
698
706
  const registry = (0, commands_1.createCommandRegistry)();
699
707
  (0, commands_1.registerDefaultCommands)(registry);
@@ -750,14 +758,24 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
750
758
  // ── Teams adapter concerns: controller, callbacks, session path ──────────
751
759
  const controller = new AbortController();
752
760
  const channelConfig = (0, config_2.getTeamsChannelConfig)();
753
- const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
754
761
  const traceId = (0, nerves_1.createTraceId)();
762
+ const observedStream = {
763
+ emit(activity) {
764
+ const result = stream.emit(activity);
765
+ reportTeamsRequest("reply_emitted", conversationId, teamsContext, { traceId, delivery: "unconfirmed_stream" });
766
+ return result;
767
+ },
768
+ update(text) { return stream.update(text); },
769
+ close: stream.close.bind(stream),
770
+ };
771
+ const callbacks = createTeamsCallbacks(observedStream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
755
772
  const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
756
773
  const teamsCapabilities = (0, friends_1.getChannelCapabilities)("teams");
757
774
  const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, "teams", conversationId);
758
775
  const runWithLease = runtimeOverrides?._withSessionTurnLease ?? session_transaction_1.withSessionTurnLease;
759
776
  try {
760
777
  await runWithLease(sessPath, async (sessionTurnLease) => {
778
+ reportTeamsRequest("running", conversationId, teamsContext, { traceId, friendId });
761
779
  // Proactive feedback acknowledgements are not magically in this model session's
762
780
  // history. Recover their frozen packet only after identity and lease ownership.
763
781
  let feedbackReply;
@@ -962,9 +980,11 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
962
980
  }
963
981
  catch (error) {
964
982
  if (error instanceof session_transaction_1.SessionTurnBusyError || (error instanceof Error && error.name === "SessionTurnBusyError")) {
983
+ reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "lease_busy" });
965
984
  stream.emit("this conversation is busy finishing another turn; please try again");
966
985
  return;
967
986
  }
987
+ reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "turn_failed" });
968
988
  throw error;
969
989
  }
970
990
  }
@@ -1159,7 +1179,11 @@ function registerBotHandlers(app, label) {
1159
1179
  const turnKey = teamsTurnKey(convId);
1160
1180
  const userId = activity.from?.id || "";
1161
1181
  const channelId = activity.channelId || "msteams";
1162
- (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.message_received", component: "channels", message: `[${label}] incoming teams message`, meta: { userId: userId.slice(0, 12), conversationId: convId.slice(0, 20) } });
1182
+ const requestContext = {
1183
+ inboundActivityId: activity.id, tenantId: activity.conversation?.tenantId,
1184
+ botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
1185
+ };
1186
+ (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.message_received", component: "channels", message: `[${label}] incoming teams message`, meta: { stage: "received", inboundActivityId: activity.id, botId: app.id, tenantId: requestContext.tenantId, userId: userId.slice(0, 12), conversationId: convId } });
1163
1187
  const commandRegistry = createTeamsCommandRegistry();
1164
1188
  const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
1165
1189
  if (parsedSlashCommand) {
@@ -1200,6 +1224,7 @@ function registerBotHandlers(app, label) {
1200
1224
  replyToId: activity.replyToId,
1201
1225
  }, async () => {
1202
1226
  try {
1227
+ reportTeamsRequest("admitted", convId, requestContext);
1203
1228
  // Resolve OAuth connection names for this user's tenant (supports per-tenant overrides).
1204
1229
  const tenantId = activity.conversation?.tenantId;
1205
1230
  const tenantOAuth = (0, config_1.resolveOAuthForTenant)(tenantId);
@@ -1225,6 +1250,7 @@ function registerBotHandlers(app, label) {
1225
1250
  catch { /* no token yet — tool handler will trigger signin */ }
1226
1251
  (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.token_status", component: "channels", message: "oauth token availability", meta: { graph: !!graphToken, ado: !!adoToken, github: !!githubToken, tenantId } });
1227
1252
  const teamsContext = {
1253
+ inboundActivityId: activity.id,
1228
1254
  replyToId: activity.replyToId,
1229
1255
  graphToken,
1230
1256
  adoToken,
@@ -1255,11 +1281,20 @@ function registerBotHandlers(app, label) {
1255
1281
  const ctxSend = async (t) => {
1256
1282
  // Use send with replyToId (not reply, which adds a blockquote).
1257
1283
  // replyToId anchors the message after the user's message in Copilot Chat.
1258
- await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
1284
+ try {
1285
+ const receipt = await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
1286
+ const messageId = receipt && typeof receipt === "object" && "id" in receipt && typeof receipt.id === "string" ? receipt.id : undefined;
1287
+ reportTeamsRequest(messageId ? "replied" : "reply_emitted", convId, teamsContext, { delivery: messageId ? "accepted" : "unconfirmed", ...(messageId ? { outboundActivityId: messageId } : {}) });
1288
+ }
1289
+ catch (error) {
1290
+ reportTeamsRequest("failed", convId, teamsContext, { reason: "send_unknown" });
1291
+ throw error;
1292
+ }
1259
1293
  };
1260
1294
  await handleTeamsMessage(text, stream, convId, teamsContext, ctxSend);
1261
1295
  }
1262
1296
  catch (err) {
1297
+ reportTeamsRequest("failed", convId, requestContext, { reason: "handler_failed" });
1263
1298
  const msg = err instanceof Error ? err.message : String(err);
1264
1299
  (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
1265
1300
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.932",
3
+ "version": "0.1.0-alpha.933",
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.932",
9
+ "version": "0.1.0-alpha.933",
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.932",
3
+ "version": "0.1.0-alpha.933",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },