omp-conductor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,9 +44,11 @@
44
44
  * config, believed it held both.
45
45
  */
46
46
 
47
+ import { createHash, randomUUID } from "node:crypto";
47
48
  import { spawnSync } from "node:child_process";
48
49
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
+ import { availabilityPrompt, interruptDisposition } from "./availability.ts";
50
52
  import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
51
53
  import {
52
54
  bridgeTokenBound,
@@ -58,6 +60,7 @@ import {
58
60
  import {
59
61
  briefPathForProject,
60
62
  policyPathForProject,
63
+ reportScopeFromPolicy,
61
64
  refreshComposedBriefForProject,
62
65
  } from "./setup.ts";
63
66
  import {
@@ -73,8 +76,11 @@ import {
73
76
  DEFAULT_REPORT_SCOPE,
74
77
  DENIED_RELEASE_GRANTS,
75
78
  type DispatchSummary,
79
+ type DigestBacklog,
76
80
  type FrictionSignal,
77
- type ReportScope,
81
+ type HeldNotice,
82
+ type MaterialEvent,
83
+ type ReportScopeChoice,
78
84
  type ReportingPolicy,
79
85
  type ResolvedGrants,
80
86
  type Store,
@@ -82,7 +88,7 @@ import {
82
88
  import { formatDecisionDigest } from "./decisions.ts";
83
89
  import type { RunRecord } from "./types.ts";
84
90
  import { dbPath, openStore } from "./store.ts";
85
- import { digestDue } from "./digest-schedule.ts";
91
+ import { digestDue, localDayKey } from "./digest-schedule.ts";
86
92
 
87
93
  /** The activation file. Absent means "this is not an orchestrator session". */
88
94
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -111,6 +117,8 @@ const FRICTION_DIGEST_LIMIT = 3;
111
117
  * definitive, either way.
112
118
  */
113
119
  const RETRY_OWNERSHIP_MS = 60_000;
120
+ /** A failed digest turn must release working-hours catch-up ownership promptly. */
121
+ const MIN_AVAILABILITY_DIGEST_LEASE_MS = 30 * 60 * 1_000;
114
122
 
115
123
  /**
116
124
  * The stall marker — written beside the activation file, in the session cwd —
@@ -258,6 +266,23 @@ interface TickApi {
258
266
  on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
259
267
  on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
260
268
  on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
269
+ on(
270
+ event: "message_start",
271
+ handler: (
272
+ event: {
273
+ type: "message_start";
274
+ message: {
275
+ role: string;
276
+ customType?: string;
277
+ synthetic?: boolean;
278
+ attribution?: "user" | "agent";
279
+ timestamp?: number;
280
+ };
281
+ },
282
+ ctx: TickContext,
283
+ ) => void,
284
+ ): void;
285
+ on(event: "agent_end", handler: (event: { type: "agent_end" }, ctx: TickContext) => void): void;
261
286
  /**
262
287
  * `deliverAs: "followUp"` + `triggerTurn: true`, verified against
263
288
  * `AgentSession.sendCustomMessage` rather than assumed:
@@ -410,14 +435,58 @@ export function queueDigestLine(
410
435
  return line;
411
436
  }
412
437
 
413
- export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
438
+ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
414
439
  material: "Report material events per your brief.",
415
440
  escalations:
416
441
  "Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
417
442
  decisions:
418
- "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
443
+ "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event waits for the configured digest; nothing between digests.",
444
+ quiet:
445
+ "Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
419
446
  };
420
447
 
448
+ /** Bounded, durable source material for one due digest. Row ids are part of the
449
+ * handoff contract: the report and exactly the rows it consumed settle in one
450
+ * SQLite transaction, so a crash cannot lose an outcome between those writes. */
451
+ export function formatDigestLedger(
452
+ events: readonly MaterialEvent[],
453
+ notices: readonly HeldNotice[],
454
+ backlog: DigestBacklog,
455
+ ): string {
456
+ const oneLine = (text: string, limit: number): string => {
457
+ const flat = text.replace(/\s+/g, " ").trim();
458
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
459
+ };
460
+ const lines = [
461
+ `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
462
+ ...events.map(
463
+ (event) =>
464
+ ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
465
+ `${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
466
+ ),
467
+ ...notices.map(
468
+ (notice) =>
469
+ ` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
470
+ `${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
471
+ ),
472
+ ];
473
+ if (backlog.materialCount > events.length) {
474
+ lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
475
+ }
476
+ if (backlog.heldNoticeCount > notices.length) {
477
+ lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
478
+ }
479
+ const eventIds = events.map((event) => event.id).join(",");
480
+ const noticeIds = notices.map((notice) => notice.id).join(",");
481
+ lines.push(
482
+ "Compose the digest from the relevant rows above. Hand it off with omp-conductor report --kind digest " +
483
+ `${eventIds.length === 0 ? "" : `--events ${eventIds} `}` +
484
+ `${noticeIds.length === 0 ? "" : `--notices ${noticeIds} `}` +
485
+ "--text TEXT. Remove any row id you did not actually include; omitted rows remain owed. Never invent an id.",
486
+ );
487
+ return lines.join("\n");
488
+ }
489
+
421
490
  /**
422
491
  * The reporting constraint appended to a default tick prompt (#229, #242).
423
492
  *
@@ -449,9 +518,13 @@ export function tickReportingConstraint(
449
518
  ? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
450
519
  : `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
451
520
  }
521
+ if (policy !== undefined && !allowed.includes("material")) {
522
+ base +=
523
+ " Record every material outcome that is not sent as an interrupt immediately with omp-conductor event record; the durable ledger, not session memory, feeds the next digest.";
524
+ }
452
525
  if (digest.cadence !== "daily") return base;
453
526
  if (digest.due) {
454
- return `${base} The daily digest is DUE now — compose it from this tick's accumulated events and the ${held} held notice(s) below, then send via omp-conductor report --kind digest.`;
527
+ return `${base} The daily digest is DUE now — compose it from the durable accumulated events and ${held} held notice(s), then send via omp-conductor report --kind digest.`;
455
528
  }
456
529
  return digest.at === undefined
457
530
  ? `${base} The daily digest was already sent today; do not send another.`
@@ -489,13 +562,14 @@ export function tickReportingConstraint(
489
562
  * report is indistinguishable from a quiet tick. #123 moved delivery to the
490
563
  * daemon, so the clause now names the handover instead. `omp-conductor report`
491
564
  * persists the text before anything is sent and the daemon retries it until it
492
- * lands; a handover that returns a report id has therefore *already* survived
493
- * the failure mode this rule exists for, which a watched `telegram_send` never
494
- * could. `telegram_send` remains the right call for an interactive reply to a
495
- * person who is waiting — that is a conversation, not a report.
565
+ * lands; a handover that returns a durable report or held-notice id has
566
+ * therefore *already* survived the failure mode this rule exists for, which a
567
+ * watched `telegram_send` never could. `telegram_send` remains the right call
568
+ * for an interactive reply to a person who is waiting — that is a
569
+ * conversation, not a report.
496
570
  */
497
571
  export const TICK_DELIVERY_RULE =
498
- "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
572
+ "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a durable handoff id (a report id, or a held-notice id during quiet hours); the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
499
573
 
500
574
  /** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
501
575
  * delivers the tick's ending text (#169). That is a narrower class than the
@@ -538,8 +612,9 @@ export { TELEGRAM_APPROVAL_TOOL };
538
612
  */
539
613
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
540
614
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
541
- `If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
542
- `never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
615
+ `If you have an amendment to propose, deliver the question with telegram_send, prefix its text with "QUESTION:", and wait for your operator's reply on a later turn. ` +
616
+ `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
617
+ `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
543
618
 
544
619
  /**
545
620
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -625,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
625
700
  * trade.
626
701
  */
627
702
  export function resolveTickScope(): {
628
- scope: ReportScope;
703
+ scope: ReportScopeChoice;
629
704
  policy?: ReportingPolicy;
630
705
  briefPath?: string;
631
706
  policyPath?: string;
@@ -635,7 +710,7 @@ export function resolveTickScope(): {
635
710
  try {
636
711
  const project = findProject(loadConfig());
637
712
  return {
638
- scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
713
+ scope: reportScopeFromPolicy(project.reporting),
639
714
  policy: project.reporting,
640
715
  briefPath: briefPathForProject(project),
641
716
  policyPath: policyPathForProject(project),
@@ -1282,6 +1357,228 @@ function clearTickRequest(pi: TickApi, cwd: string): void {
1282
1357
  }
1283
1358
  }
1284
1359
 
1360
+ interface PendingLocalTick {
1361
+ id: string;
1362
+ projectName?: string;
1363
+ policy?: ReportingPolicy;
1364
+ }
1365
+
1366
+ interface ActiveLocalTick extends PendingLocalTick {
1367
+ /** A person who writes during an autonomous run is awake by construction. */
1368
+ humanWaiting: boolean;
1369
+ }
1370
+
1371
+ interface TelegramInterrupt {
1372
+ kind: "question" | "message";
1373
+ category: HeldNotice["category"];
1374
+ summary: string;
1375
+ detail: string;
1376
+ hasFiles: boolean;
1377
+ fingerprint: string;
1378
+ }
1379
+
1380
+ function objectRecord(value: unknown): value is Record<string, unknown> {
1381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1382
+ }
1383
+ function canonicalTelegramPayload(value: unknown): unknown {
1384
+ if (Array.isArray(value)) return value.map(canonicalTelegramPayload);
1385
+ if (!objectRecord(value)) return value;
1386
+ return Object.fromEntries(
1387
+ Object.keys(value)
1388
+ .sort()
1389
+ .map((key) => [key, canonicalTelegramPayload(value[key])]),
1390
+ );
1391
+ }
1392
+
1393
+
1394
+ function serialiseTelegramPayload(value: unknown): string {
1395
+ try {
1396
+ return JSON.stringify(canonicalTelegramPayload(value)) ?? String(value);
1397
+ } catch {
1398
+ return String(value);
1399
+ }
1400
+ }
1401
+
1402
+ function telegramInterruptFromTool(
1403
+ toolName: string,
1404
+ input: Record<string, unknown>,
1405
+ ): TelegramInterrupt | undefined {
1406
+ let kind: TelegramInterrupt["kind"];
1407
+ let payload: Record<string, unknown> = input;
1408
+
1409
+ if (toolName === TELEGRAM_APPROVAL_TOOL) {
1410
+ kind = "question";
1411
+ } else if (toolName === "telegram_send" || toolName === "telegram_react") {
1412
+ kind = "message";
1413
+ } else if (toolName === "write") {
1414
+ const path = input["path"];
1415
+ if (
1416
+ path !== "xd://telegram_ask" &&
1417
+ path !== "xd://telegram_send" &&
1418
+ path !== "xd://telegram_react"
1419
+ ) {
1420
+ return undefined;
1421
+ }
1422
+ kind = path === "xd://telegram_ask" ? "question" : "message";
1423
+ const content = input["content"];
1424
+ if (typeof content === "string") {
1425
+ try {
1426
+ const parsed: unknown = JSON.parse(content);
1427
+ payload = objectRecord(parsed) ? parsed : { content };
1428
+ } catch {
1429
+ payload = { content };
1430
+ }
1431
+ }
1432
+ } else {
1433
+ return undefined;
1434
+ }
1435
+ if (
1436
+ kind === "message" &&
1437
+ typeof payload["text"] === "string" &&
1438
+ /^\s*QUESTION:\s/i.test(payload["text"])
1439
+ ) {
1440
+ // The package floor uses this explicit fallback when telegram_ask is not
1441
+ // mounted. Without the marker a plain send remains a material update.
1442
+ kind = "question";
1443
+ }
1444
+
1445
+ let detail = "";
1446
+ if (kind === "question" && Array.isArray(payload["questions"])) {
1447
+ detail = payload["questions"]
1448
+ .map((value, index) => {
1449
+ if (!objectRecord(value)) return `Question ${index + 1}: ${serialiseTelegramPayload(value)}`;
1450
+ const question = typeof value["question"] === "string" ? value["question"] : "Question";
1451
+ const options = Array.isArray(value["options"])
1452
+ ? value["options"]
1453
+ .map((option) =>
1454
+ objectRecord(option) && typeof option["label"] === "string"
1455
+ ? `${option["label"]}${
1456
+ typeof option["description"] === "string" && option["description"] !== ""
1457
+ ? ` — ${option["description"]}`
1458
+ : ""
1459
+ }`
1460
+ : serialiseTelegramPayload(option),
1461
+ )
1462
+ .join("; ")
1463
+ : "";
1464
+ return options === "" ? question : `${question}\nOptions: ${options}`;
1465
+ })
1466
+ .join("\n\n");
1467
+ } else if (typeof payload["text"] === "string") {
1468
+ detail = payload["text"];
1469
+ }
1470
+ if (detail.trim() === "") detail = serialiseTelegramPayload(payload);
1471
+
1472
+ const flat = detail.replace(/\s+/g, " ").trim();
1473
+ const fallback = kind === "question" ? "Operator question" : "Telegram message";
1474
+ const summary = flat === "" ? fallback : flat.length <= 180 ? flat : `${flat.slice(0, 179)}…`;
1475
+ const canonical = serialiseTelegramPayload({ kind, payload });
1476
+ return {
1477
+ kind,
1478
+ category: kind === "question" ? "decision-needed" : "material",
1479
+ summary,
1480
+ hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
1481
+ detail,
1482
+ fingerprint: createHash("sha256").update(canonical).digest("hex"),
1483
+ };
1484
+ }
1485
+
1486
+ type TelegramInterruptBlock = { block: true; reason: string };
1487
+
1488
+ /**
1489
+ * A locally injected tick is an autonomous actor. Its direct Telegram calls
1490
+ * therefore pass through the same category and availability decision as daemon
1491
+ * escalations. Human-originated runs, including a message steered into a tick,
1492
+ * are exempt: the operator is already present and waiting for the reply.
1493
+ */
1494
+ function autonomousTelegramInterruptBlock(
1495
+ pi: TickApi,
1496
+ session: TickSession,
1497
+ event: { toolName: string; input: Record<string, unknown> },
1498
+ ): TelegramInterruptBlock | undefined {
1499
+ const active = session.activeLocalTick;
1500
+ if (active === undefined || active.humanWaiting) return undefined;
1501
+ const interrupt = telegramInterruptFromTool(event.toolName, event.input);
1502
+ if (interrupt === undefined) return undefined;
1503
+ if (active.projectName === undefined) {
1504
+ return {
1505
+ block: true,
1506
+ reason:
1507
+ "Operator availability policy is unavailable for this autonomous tick, so direct Telegram is blocked fail-closed. " +
1508
+ "Repair the conductor config; do not retry through another Telegram path.",
1509
+ };
1510
+ }
1511
+
1512
+ let policy = active.policy;
1513
+ let configReadable = true;
1514
+ try {
1515
+ policy = findProject(loadConfig(), active.projectName).reporting;
1516
+ } catch (err) {
1517
+ configReadable = false;
1518
+ pi.logger.error(
1519
+ `[omp-conductor] operator availability config unreadable during Telegram call; blocking fail-closed: ${
1520
+ err instanceof Error ? err.message : String(err)
1521
+ }`,
1522
+ );
1523
+ }
1524
+
1525
+ const at = Date.now();
1526
+ const disposition = configReadable
1527
+ ? interruptDisposition(policy, interrupt.category, at)
1528
+ : "availability";
1529
+ if (disposition === "interrupt") return undefined;
1530
+ const release =
1531
+ disposition === "availability"
1532
+ ? "the availability window opens or the daily digest runs"
1533
+ : "the daily digest runs";
1534
+ if (interrupt.hasFiles) {
1535
+ return {
1536
+ block: true,
1537
+ reason:
1538
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind} until ${release}, ` +
1539
+ "and attachment-bearing Telegram sends cannot be durably replayed. Nothing was sent or held. " +
1540
+ "Do not retry through another Telegram path while the policy forbids delivery.",
1541
+ };
1542
+ }
1543
+
1544
+ const key = `${active.projectName}:autonomous-telegram:${active.id}:${interrupt.fingerprint}`;
1545
+ try {
1546
+ const store = openStore(dbPath());
1547
+ try {
1548
+ if (!store.wasNotified(key)) {
1549
+ store.addHeldNotice({
1550
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
1551
+ project: active.projectName,
1552
+ category: interrupt.category,
1553
+ summary: interrupt.summary,
1554
+ detail: interrupt.detail,
1555
+ createdAt: at,
1556
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
1557
+ });
1558
+ store.markNotified(key);
1559
+ }
1560
+ } finally {
1561
+ store.close();
1562
+ }
1563
+ } catch (err) {
1564
+ const reason = err instanceof Error ? err.message : String(err);
1565
+ pi.logger.error(`[omp-conductor] could not persist held Telegram ${interrupt.kind}: ${reason}`);
1566
+ return {
1567
+ block: true,
1568
+ reason:
1569
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind}, and the durable hold failed: ${reason}. ` +
1570
+ "Do not retry through another Telegram path.",
1571
+ };
1572
+ }
1573
+
1574
+ return {
1575
+ block: true,
1576
+ reason:
1577
+ `Operator policy durably held this autonomous Telegram ${interrupt.kind} until ${release}. ` +
1578
+ "Do not retry through another Telegram path.",
1579
+ };
1580
+ }
1581
+
1285
1582
  /** Everything one tick remembers for the next. */
1286
1583
  interface TickSession {
1287
1584
  /**
@@ -1310,6 +1607,10 @@ interface TickSession {
1310
1607
  bridgeTokenAtStart: boolean;
1311
1608
  /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
1312
1609
  pendingSkips: number;
1610
+ /** Ticks sent as follow-ups but not yet observed by the agent loop. */
1611
+ pendingLocalTicks: PendingLocalTick[];
1612
+ /** The local tick whose agent loop is currently running, if any. */
1613
+ activeLocalTick?: ActiveLocalTick;
1313
1614
  }
1314
1615
 
1315
1616
  /**
@@ -1393,22 +1694,68 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1393
1694
  try {
1394
1695
  const lastKey = store.lastDigestDedupeKey(scope.projectName);
1395
1696
  const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1697
+ const due = digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at);
1698
+ // Reserve availability-held rows before reading them. The same SQLite
1699
+ // transaction releases pending catch-ups; daemon enqueue attempts then
1700
+ // observe the lease, closing both orders of the snapshot-to-handoff race.
1701
+ const availabilityReserved =
1702
+ due &&
1703
+ store.reserveAvailabilityDigest(
1704
+ scope.projectName,
1705
+ localDayKey(at, digestPolicy.timezone ?? policy?.availability?.timezone),
1706
+ at,
1707
+ at +
1708
+ Math.max(
1709
+ MIN_AVAILABILITY_DIGEST_LEASE_MS,
1710
+ (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1_000 + 5 * 60_000,
1711
+ ),
1712
+ );
1713
+ const backlog = store.digestBacklog(scope.projectName);
1714
+ const visibleBacklog: DigestBacklog = availabilityReserved
1715
+ ? backlog
1716
+ : {
1717
+ ...backlog,
1718
+ heldNoticeCount: Math.max(
1719
+ 0,
1720
+ backlog.heldNoticeCount - (backlog.availabilityHeldNoticeCount ?? 0),
1721
+ ),
1722
+ };
1396
1723
  reportingConstraint = tickReportingConstraint(
1397
1724
  policy,
1398
1725
  {
1399
- due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
1726
+ due,
1400
1727
  cadence,
1401
1728
  at: digestPolicy.at,
1402
1729
  timezone: digestPolicy.timezone,
1403
1730
  },
1404
- store.undigestedNotices(scope.projectName).length,
1731
+ visibleBacklog.heldNoticeCount,
1405
1732
  );
1733
+ if (
1734
+ due &&
1735
+ (visibleBacklog.materialCount > 0 || visibleBacklog.heldNoticeCount > 0)
1736
+ ) {
1737
+ const notices = store
1738
+ .undigestedNotices(scope.projectName)
1739
+ .filter((notice) => notice.releaseOnAvailable !== true || availabilityReserved);
1740
+ reportingConstraint +=
1741
+ `\n${formatDigestLedger(
1742
+ store.undigestedMaterialEvents(scope.projectName),
1743
+ notices,
1744
+ visibleBacklog,
1745
+ )}`;
1746
+ }
1406
1747
  } finally {
1407
1748
  store.close();
1408
1749
  }
1409
1750
  }
1410
1751
  content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1411
1752
  }
1753
+ if (scope.projectName !== undefined) {
1754
+ // Unlike ordinary reporting prose, this is a mechanical clock reading and
1755
+ // survives a custom heartbeat message. The model never infers whether the
1756
+ // operator can be interrupted.
1757
+ content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
1758
+ }
1412
1759
  let frictionStore: Store | undefined;
1413
1760
  let frictionSignals: FrictionSignal[] = [];
1414
1761
  const now = Date.now();
@@ -1526,11 +1873,23 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1526
1873
  // No `accessFile` means no fleet bridge to judge, exactly as above.
1527
1874
  if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1528
1875
 
1876
+ const pendingLocalTick: PendingLocalTick = {
1877
+ id: randomUUID(),
1878
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
1879
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
1880
+ };
1881
+ session.pendingLocalTicks.push(pendingLocalTick);
1529
1882
  try {
1530
- pi.sendMessage(
1531
- { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1532
- { triggerTurn: true, deliverAs: "followUp" },
1533
- );
1883
+ try {
1884
+ pi.sendMessage(
1885
+ { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1886
+ { triggerTurn: true, deliverAs: "followUp" },
1887
+ );
1888
+ } catch (err) {
1889
+ const pendingIndex = session.pendingLocalTicks.indexOf(pendingLocalTick);
1890
+ if (pendingIndex >= 0) session.pendingLocalTicks.splice(pendingIndex, 1);
1891
+ throw err;
1892
+ }
1534
1893
  if (scope.projectName !== undefined && frictionSignals.length > 0) {
1535
1894
  try {
1536
1895
  frictionStore?.markFrictionSurfaced(
@@ -1645,8 +2004,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1645
2004
  approvalToolMissingLogged: false,
1646
2005
  bridgeTokenAtStart: true,
1647
2006
  pendingSkips: 0,
2007
+ pendingLocalTicks: [],
1648
2008
  };
1649
2009
  let releaseGateArmed = false;
2010
+ let availabilityGateArmed = false;
1650
2011
  let guardArmed = false;
1651
2012
  // An activation file makes this a fleet directory before Herdr can prove
1652
2013
  // which pane owns it. The gate therefore starts closed and only honours a
@@ -1716,6 +2077,61 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1716
2077
  });
1717
2078
  };
1718
2079
 
2080
+ const armAvailabilityGate = (): void => {
2081
+ if (availabilityGateArmed) return;
2082
+ availabilityGateArmed = true;
2083
+
2084
+ // Custom messages sent with `pi.sendMessage` bypass `before_agent_start`.
2085
+ // `message_start` is the first lifecycle event that carries their exact
2086
+ // custom type, so it is the reliable boundary between a local tick and an
2087
+ // inbound human turn.
2088
+ pi.on("message_start", (event) => {
2089
+ const message = event.message;
2090
+ if (message.role === "custom" && message.customType === TICK_CUSTOM_TYPE) {
2091
+ const pending = session.pendingLocalTicks.shift();
2092
+ if (pending !== undefined) {
2093
+ session.activeLocalTick = { ...pending, humanWaiting: false };
2094
+ } else {
2095
+ // A queued custom tick can survive a host restart after the in-memory
2096
+ // enqueue record does not. Reconstruct its scope so recovery cannot
2097
+ // silently turn an autonomous run into an interactive one.
2098
+ const scope = resolveTickScope();
2099
+ const id =
2100
+ typeof message.timestamp === "number"
2101
+ ? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
2102
+ : randomUUID();
2103
+ session.activeLocalTick = {
2104
+ id,
2105
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
2106
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
2107
+ humanWaiting: false,
2108
+ };
2109
+ }
2110
+ return;
2111
+ }
2112
+ if (
2113
+ session.activeLocalTick !== undefined &&
2114
+ message.role === "user" &&
2115
+ message.synthetic !== true &&
2116
+ message.attribution !== "agent"
2117
+ ) {
2118
+ session.activeLocalTick.humanWaiting = true;
2119
+ }
2120
+ });
2121
+ pi.on("agent_end", () => {
2122
+ session.activeLocalTick = undefined;
2123
+ });
2124
+ (pi as TickApi & {
2125
+ on(
2126
+ event: "tool_call",
2127
+ handler: (
2128
+ event: { toolName: string; input: Record<string, unknown> },
2129
+ ctx: unknown,
2130
+ ) => TelegramInterruptBlock | undefined,
2131
+ ): void;
2132
+ }).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
2133
+ };
2134
+
1719
2135
  pi.on("session_start", (_event, ctx) => {
1720
2136
  if (decided) return;
1721
2137
 
@@ -1743,6 +2159,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1743
2159
  // Present but invalid still identifies a fleet directory. Install the
1744
2160
  // fail-closed handler before validation or ownership can return early.
1745
2161
  armReleaseGate();
2162
+ armAvailabilityGate();
1746
2163
 
1747
2164
  if (result.kind === "invalid") {
1748
2165
  const detail = `${result.path}: ${result.problem}`;