omp-conductor 0.16.2 → 0.17.1

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 (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
package/src/doctor.ts CHANGED
@@ -54,16 +54,29 @@ import {
54
54
  DEFAULT_HERDR_UNIT,
55
55
  probeTelegramHealth,
56
56
  resolveHerdrSessionWithBridge,
57
+ resolveTickConfig,
57
58
  sessionsRoot,
58
59
  sessionDirForCwd,
59
60
  telegramStateDir,
60
61
  } from "./fleet.ts";
61
62
  import type { TelegramHealth } from "./status-render.ts";
62
63
  import {
63
- claimForProject,
64
64
  claimedTelegramTopics,
65
+ lockPidAlive,
66
+ pidAlive,
67
+ readTelegramChannel,
68
+ readTelegramDmOwner,
69
+ readTelegramPollState,
70
+ resolveClaimedSessionFile,
71
+ resolveProjectClaim,
72
+ resolveProjectTopicId,
73
+ telegramPlumbingVerdict,
65
74
  telegramTopicsTidy,
66
- type ClaimedTopic,
75
+ TELEGRAM_LOCK_FRESH_MS,
76
+ type ClaimedTopicsResult,
77
+ type TelegramChannelState,
78
+ type TelegramDmOwnerState,
79
+ type TelegramPollState,
67
80
  } from "./escalate.ts";
68
81
  import {
69
82
  herdrConductorPluginConfigDir,
@@ -212,10 +225,44 @@ export interface DoctorDeps {
212
225
  herdrConfig?: () => string | undefined;
213
226
  /** The live herdr-conductor plugin `config.env`, or undefined. */
214
227
  herdrEnv?: () => string | undefined;
215
- /** Live omp-telegram topic claims (threads.json), for the #600 probes. */
216
- claimedTopics?: () => ClaimedTopic[];
228
+ /** Live omp-telegram topic claims (threads.json), for the #600 probes.
229
+ * `unavailable` when the registry cannot be read or does not parse — the
230
+ * caller must not read that as "no claims" (#626). */
231
+ claimedTopics?: () => ClaimedTopicsResult;
217
232
  /** Whether the bridge's access.json runs per-session topic tidy on. */
218
233
  topicsTidy?: () => boolean;
234
+ /** The typed bot.lock poll-ownership read, told apart so absent/malformed
235
+ * state is a #612 failure — the lock, not daemon.json, is what says whether
236
+ * the bridge is actually polling. */
237
+ pollState?: () => TelegramPollState;
238
+ /** The typed dm-owner.json read — the untopiced-DM recipient a flat
239
+ * challenge's reply routes through (`/telegram own`). Absent/malformed is a
240
+ * named "no pinned owner", not a pass. */
241
+ dmOwner?: () => TelegramDmOwnerState;
242
+ /** The paired inbound channel state, probed exactly as armTicks probes it
243
+ * (readTelegramChannel over the tick's accessFile). */
244
+ channelState?: (project: ProjectConfig) => TelegramChannelState;
245
+ /** Whether the project can ride inbound at all — a readable tick config
246
+ * naming an accessFile. The tick runtime treats a missing accessFile as no
247
+ * fleet bridge, so that shape is neutral, never a fault. */
248
+ projectArms?: (project: ProjectConfig) => boolean;
249
+ /** The topic id an arm challenge would send into for this project —
250
+ * resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
251
+ armSendTopic?: (project: ProjectConfig) => number | undefined;
252
+ /** The session directories arm scans for the challenge reply on this
253
+ * project — tick-cwd-derived plus the live claim's own directory. */
254
+ armScanDirs?: (project: ProjectConfig) => readonly string[];
255
+ /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
256
+ * topics.ts semantics (EPERM is dead). */
257
+ pidAlive?: (pid: number) => boolean;
258
+ /** Whether a bot.lock owner pid is live, with omp-telegram's api.ts
259
+ * semantics (EPERM is live). Distinct from {@link pidAlive} because the
260
+ * bridge itself uses two rules. */
261
+ lockPidAlive?: (pid: number) => boolean;
262
+ /** Whether a lock-file mtime is younger than LOCK_FRESH_MS — the bridge's
263
+ * own liveness rule: a fresh heartbeat keeps the poll live even when the
264
+ * recorded pid is gone. */
265
+ lockFresh?: (mtimeMs: number) => boolean;
219
266
  /** The fleet agent name the tick config of one project names, or undefined
220
267
  * when there is no (readable) tick — the expected live herdr pane identity. */
221
268
  tickAgentName?: (project: ProjectConfig) => string | undefined;
@@ -1094,15 +1141,52 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
1094
1141
  * doctor fails rather than letting the fleet sit disarmed.
1095
1142
  */
1096
1143
  function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1097
- const claims = probes.claimedTopics();
1098
- if (claims.length === 0) {
1144
+ const result = probes.claimedTopics();
1145
+ if (result.kind !== "ok") {
1146
+ // The registry could not be read, so the claim's session file is unknown,
1147
+ // not known-absent. Arm scans the tick-cwd directory either way, but the
1148
+ // finding must stay diagnostic — this is exactly the shape that once made
1149
+ // arming silently impossible, and "no claim" would certify it as fine. A
1150
+ // missing registry is the same unknown to this probe: the bridge may never
1151
+ // have claimed, but nothing proves the claim names no session file.
1152
+ return warnFinding(
1153
+ "arm-session-dir",
1154
+ `[${p.name}] ` +
1155
+ (result.kind === "missing"
1156
+ ? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
1157
+ : `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
1158
+ `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
1159
+ );
1160
+ }
1161
+ if (result.claims.length === 0) {
1099
1162
  return passFinding(
1100
1163
  "arm-session-dir",
1101
1164
  `[${p.name}] no live omp-telegram claim — arm scans the tick-cwd session directory`,
1102
1165
  );
1103
1166
  }
1104
- const claim = claimForProject(claims, p.name);
1105
- if (claim === undefined || claim.sessionFile === undefined) {
1167
+ const claims = result.claims;
1168
+ const match = resolveProjectClaim(claims, p.name);
1169
+ const scanDir = sessionDirForCwd(tickCwdForProject(p));
1170
+ if (match.kind === "ambiguous") {
1171
+ // Several live claims answer to the project, so the claim's session file
1172
+ // cannot be told from a sibling pane's. Arm scans the tick-cwd directory
1173
+ // either way, but certifying that fallback as the scan is the miss this
1174
+ // probe exists to surface: the resumed transcript may live under another
1175
+ // claim while doctor reads the fallback as the whole story (#626).
1176
+ return warnFinding(
1177
+ "arm-session-dir",
1178
+ `[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
1179
+ `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
1180
+ );
1181
+ }
1182
+ const claim = match.kind === "match" ? match.claim : undefined;
1183
+ if (claim === undefined) {
1184
+ return passFinding(
1185
+ "arm-session-dir",
1186
+ `[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
1187
+ );
1188
+ }
1189
+ if (claim.sessionFile === undefined) {
1106
1190
  return passFinding(
1107
1191
  "arm-session-dir",
1108
1192
  `[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
@@ -1110,7 +1194,6 @@ function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1110
1194
  }
1111
1195
  const root = sessionsRoot();
1112
1196
  const claimDir = dirname(claim.sessionFile);
1113
- const scanDir = sessionDirForCwd(tickCwdForProject(p));
1114
1197
  if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1115
1198
  return failFinding(
1116
1199
  "arm-session-dir",
@@ -1138,43 +1221,208 @@ function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1138
1221
  *
1139
1222
  * The wizard pins from the claims at setup time, but the bridge re-claims
1140
1223
  * topics across restarts and conductor restarts the service itself (#407), so
1141
- * the pin drifts. On a host with `topicsTidy` on it drifts *structurally*:
1142
- * every pane exit closes the topic, so a pinned id can never be live again.
1143
- * There the pin is vestigial reporting a dead id as the live destination is
1144
- * the exact false belief this check exists to remove.
1224
+ * the pin drifts. A stale pin is not itself a fault: {@link
1225
+ * resolveProjectTopicId} follows the project's one unambiguous live claim in
1226
+ * that shape (#407/#412), so routing is healthy exactly when a claim answers
1227
+ * to the project. The pin is a fault only when no live claim can take its
1228
+ * place — then sends keep the stale pin and degrade to the flat chat, and the
1229
+ * fix is to re-pin. Removing the pin is never the fix: a missing pin disables
1230
+ * substitution entirely and opts the project into flat chat (#626).
1231
+ *
1232
+ * A registry that cannot be read is neither proof of health nor a stale pin:
1233
+ * the bridge may be fine and the pin live, but nothing can be verified, so the
1234
+ * finding warns and names restoring the registry — never "the pin stands".
1235
+ *
1236
+ * On a host with `topicsTidy` on, a pin drifts *structurally*: every pane exit
1237
+ * closes the topic, so no re-pin survives and the durable fix is to turn tidy
1238
+ * off and re-pin.
1145
1239
  */
1146
1240
  function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1147
1241
  const pinned = p.escalation.telegramTopicId;
1148
1242
  if (pinned === undefined) {
1149
1243
  return passFinding(
1150
1244
  "topic-pin",
1151
- `[${p.name}] no pinned topic id — sends follow the live claim or the flat chat`,
1245
+ `[${p.name}] no pinned topic id — sends go to the flat chat (live-claim substitution only follows a stale pin)`,
1246
+ );
1247
+ }
1248
+ const result = probes.claimedTopics();
1249
+ if (result.kind !== "ok") {
1250
+ return warnFinding(
1251
+ "topic-pin",
1252
+ `[${p.name}] pinned topic ${pinned} — ` +
1253
+ (result.kind === "missing"
1254
+ ? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project`
1255
+ : `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project`),
1256
+ `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
1152
1257
  );
1153
1258
  }
1154
- const claims = probes.claimedTopics();
1259
+ const claims = result.claims;
1155
1260
  if (claims.length === 0) {
1156
1261
  return passFinding(
1157
1262
  "topic-pin",
1158
- `[${p.name}] pinned topic ${pinned} — no live claims to compare (bridge state unreadable, or not a forum host)`,
1263
+ `[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
1159
1264
  );
1160
1265
  }
1161
1266
  if (claims.some((claim) => claim.threadId === pinned)) {
1162
1267
  return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim`);
1163
1268
  }
1269
+ const match = resolveProjectClaim(claims, p.name);
1270
+ if (match.kind === "match") {
1271
+ return passFinding(
1272
+ "topic-pin",
1273
+ `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, but the ` +
1274
+ `${match.claim.workspaceLabel === p.name ? "herdr space claim" : "claim"} for this project ` +
1275
+ `(topic ${match.claim.threadId}) is live — sends follow it, so topic routing is healthy`,
1276
+ );
1277
+ }
1278
+ if (match.kind === "ambiguous") {
1279
+ // Several live claims answer to the project, so substitution would be a
1280
+ // coin toss and sends keep the stale pin. This is not the no-answer case:
1281
+ // a claim IS waiting for the project, and naming the claimants tells the
1282
+ // operator exactly which panes to disambiguate (#626).
1283
+ return warnFinding(
1284
+ "topic-pin",
1285
+ `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and several live claims answer to this project ` +
1286
+ `(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin`,
1287
+ `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — live-claim substitution follows the unique claim from there`,
1288
+ );
1289
+ }
1164
1290
  if (probes.topicsTidy()) {
1165
1291
  return warnFinding(
1166
1292
  "topic-pin",
1167
- `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and this host runs topicsTidy — ` +
1168
- `the pin is vestigial and can never be live again (every pane exit closes its topic)`,
1169
- `remove escalation.telegramTopicId from ${p.name}'s config (sends already follow the live claim), or turn topicsTidy off`,
1293
+ `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, no live claim answers ` +
1294
+ `to this project, and this host runs topicsTidy — the pin is vestigial and can never be live again ` +
1295
+ `(every pane exit closes its topic); sends keep the stale pin`,
1296
+ `turn topicsTidy off, then re-run setup to re-pin escalation.telegramTopicId to a currently claimed topic`,
1170
1297
  );
1171
1298
  }
1172
1299
  return warnFinding(
1173
1300
  "topic-pin",
1174
- `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}) ` +
1175
- `sends fall back to the live claim or the flat chat`,
1176
- `update or remove escalation.telegramTopicId in ${p.name}'s config`,
1301
+ `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}), ` +
1302
+ `and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat`,
1303
+ `update escalation.telegramTopicId to a currently claimed topic, or re-run setup to re-pin`,
1304
+ );
1305
+ }
1306
+
1307
+ /**
1308
+ * #612/#636 check — whether omp-telegram plumbing is live, *before* the arm
1309
+ * challenge, on the route the challenge actually rides. Read-only — the state
1310
+ * files and liveness probes, no `getUpdates`, no transcript scan, no network.
1311
+ * Renders the shared verdict as its own finding so a dead recipient or dead
1312
+ * poll is immediate and named, not discovered 300 seconds into an arm
1313
+ * challenge.
1314
+ *
1315
+ * Gated on the project's *real* inbound requirement, never on
1316
+ * `escalation.telegramTopicId` (an outbound send destination, not the inbound
1317
+ * shape) and never on `escalation.telegramChatId`, which `armTicks` ignores —
1318
+ * it sends the challenge to the paired owner from the tick's `accessFile`.
1319
+ * Only a project that can arm — a readable tick config naming an `accessFile`
1320
+ * — rides inbound at all, so only that shape runs the verdict: an armed
1321
+ * project with no chat id must still be verified, and a manually pinned
1322
+ * outbound-only target must not fail for absent claim/poll state. The route
1323
+ * is the forum topic the send would use (`resolveProjectTopicId` — the pin,
1324
+ * or the live claim a stale pin substitutes) when one resolves; with no topic
1325
+ * the challenge is unthreaded and the reply routes through the bridge's DM
1326
+ * owner, or — after `/telegram own clear` — the polling session itself. The
1327
+ * finding always renders, so the stable report shape and exit code are
1328
+ * unchanged.
1329
+ */
1330
+ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1331
+ if (!probes.projectArms(p)) {
1332
+ return passFinding(
1333
+ "telegram-plumbing",
1334
+ `[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
1335
+ );
1336
+ }
1337
+ const verdict = telegramPlumbingVerdict(
1338
+ probes.armSendTopic(p),
1339
+ { dirs: probes.armScanDirs(p) },
1340
+ {
1341
+ channel: probes.channelState(p),
1342
+ registry: probes.claimedTopics(),
1343
+ poll: probes.pollState(),
1344
+ dmOwner: probes.dmOwner(),
1345
+ alive: probes.pidAlive,
1346
+ lockAlive: probes.lockPidAlive,
1347
+ fresh: probes.lockFresh,
1348
+ },
1177
1349
  );
1350
+ if (verdict.ok) {
1351
+ const named = verdict.proof.includes("claim")
1352
+ ? "verified live claim + live poll"
1353
+ : verdict.proof.includes("dm-owner")
1354
+ ? "verified live dm-owner + live poll"
1355
+ : "verified live local poller session";
1356
+ return passFinding(
1357
+ "telegram-plumbing",
1358
+ `[${p.name}] live omp-telegram plumbing: ${named} (${verdict.proof.join(" + ")})`,
1359
+ );
1360
+ }
1361
+ switch (verdict.reason) {
1362
+ case "channel-down": {
1363
+ const channel = probes.channelState(p);
1364
+ const reason = channel.kind === "down" ? channel.reason : "unexpected channel state";
1365
+ return failFinding(
1366
+ "telegram-plumbing",
1367
+ `[${p.name}] the tick's accessFile does not pair an enabled bridge (${reason}) — an arm challenge could not be sent`,
1368
+ `pair the bot (/telegram pair) and enable the bridge (/telegram on), then re-run doctor`,
1369
+ );
1370
+ }
1371
+ case "registry-unreadable":
1372
+ return failFinding(
1373
+ "telegram-plumbing",
1374
+ `[${p.name}] omp-telegram's threads.json is absent, unreadable or malformed — the pinned-or-claimed topic's liveness cannot be verified`,
1375
+ `check the bridge state dir for a valid threads.json (the bridge writes it when a forum pane claims a topic)`,
1376
+ );
1377
+ case "no-topic-claim":
1378
+ return failFinding(
1379
+ "telegram-plumbing",
1380
+ `[${p.name}] no live omp-telegram claim carries the topic arming would send into — the pin is stale and no live claim takes its place`,
1381
+ `re-pin escalation.telegramTopicId to a currently claimed topic (or remove it to route the challenge flat), then re-run doctor`,
1382
+ );
1383
+ case "claim-dead":
1384
+ return failFinding(
1385
+ "telegram-plumbing",
1386
+ `[${p.name}] the topic's omp-telegram claim records no live pid — the pane behind it is gone`,
1387
+ `restart the claiming pane so the bridge re-claims it with a live pid`,
1388
+ );
1389
+ case "claim-session-outside":
1390
+ return failFinding(
1391
+ "telegram-plumbing",
1392
+ `[${p.name}] the topic's claim names a session file outside the session directories arm scans — the challenge reply could never be seen`,
1393
+ `resume the claiming pane's session under ${sessionsRoot()}, or from the tick cwd ${sessionDirForCwd(tickCwdForProject(p))}, then re-run doctor`,
1394
+ );
1395
+ case "no-dm-owner":
1396
+ return failFinding(
1397
+ "telegram-plumbing",
1398
+ `[${p.name}] no flat-chat challenge could pass: no dm-owner.json, and bot.lock names no session inside the arm scan surface — the private reply has no recipient arm can see`,
1399
+ `pin the orchestrator session as the DM owner (/telegram own in that pane), or pin escalation.telegramTopicId so the challenge rides a claimed forum topic`,
1400
+ );
1401
+ case "dm-owner-dead":
1402
+ return failFinding(
1403
+ "telegram-plumbing",
1404
+ `[${p.name}] omp-telegram's dm-owner.json records no live pid — the pane that receives flat replies is gone`,
1405
+ `restart the pane that owns dm-owner.json so the bridge re-pins it with a live pid`,
1406
+ );
1407
+ case "dm-owner-unrelated":
1408
+ return failFinding(
1409
+ "telegram-plumbing",
1410
+ `[${p.name}] the dm-owner.json session sits outside the arm scan surface — a flat reply would land in a transcript no challenge can read`,
1411
+ `pin the orchestrator session as the DM owner (/telegram own in this project's pane), or pin escalation.telegramTopicId so the challenge rides a claimed forum topic`,
1412
+ );
1413
+ case "no-poller-state":
1414
+ return failFinding(
1415
+ "telegram-plumbing",
1416
+ `[${p.name}] omp-telegram's bot.lock is absent, unreadable or malformed — nothing owns the poll, so nothing is receiving inbound`,
1417
+ `check the bridge state dir for a bot.lock held by a live poller (the daemon releases it on a getMe or poller failure)`,
1418
+ );
1419
+ case "poller-dead":
1420
+ return failFinding(
1421
+ "telegram-plumbing",
1422
+ `[${p.name}] omp-telegram's bot.lock records no live owner and no fresh heartbeat — Telegram isn't being polled`,
1423
+ `restart the omp-telegram bridge so a poller re-acquires bot.lock`,
1424
+ );
1425
+ }
1178
1426
  }
1179
1427
 
1180
1428
  /**
@@ -1375,6 +1623,9 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1375
1623
  findings.push(
1376
1624
  passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
1377
1625
  );
1626
+ findings.push(
1627
+ passFinding("telegram-plumbing", projectProblem === undefined ? "no project resolved — nothing to check" : `telegram plumbing uncheckable: ${projectProblem}`),
1628
+ );
1378
1629
  findings.push(
1379
1630
  passFinding("omp-settings", projectProblem === undefined ? "no project resolved — nothing to check" : `omp settings overlay uncheckable: ${projectProblem}`),
1380
1631
  );
@@ -1384,6 +1635,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1384
1635
  findings.push(herdrAgentNameProbe(probes, p));
1385
1636
  findings.push(armSessionDirProbe(probes, p));
1386
1637
  findings.push(topicPinProbe(probes, p));
1638
+ findings.push(telegramPlumbingProbe(probes, p));
1387
1639
  findings.push(ompSettingsProbe(p, sessionRootState));
1388
1640
  }
1389
1641
  }
@@ -1450,6 +1702,42 @@ export function defaultProbes(): Probes {
1450
1702
  herdrEnv: defaultHerdrEnv,
1451
1703
  claimedTopics: () => claimedTelegramTopics(),
1452
1704
  topicsTidy: () => telegramTopicsTidy(),
1705
+ pollState: () => readTelegramPollState(),
1706
+ dmOwner: () => readTelegramDmOwner(),
1707
+ channelState: (p) => {
1708
+ // The exact tick accessFile armTicks probes — never the state-dir
1709
+ // default, so a project whose tick pairs a different bridge is judged
1710
+ // against its own file.
1711
+ const tick = resolveTickConfig(p.name);
1712
+ if (tick.kind !== "ok" || tick.config.accessFile === undefined) {
1713
+ return { kind: "down", reason: "no tick accessFile" };
1714
+ }
1715
+ return readTelegramChannel(tick.config.accessFile);
1716
+ },
1717
+ projectArms: (p) => {
1718
+ // A tick config only carries inbound when it names an accessFile: the
1719
+ // tick runtime treats a missing one as no fleet bridge (and armTicks
1720
+ // throws without it), so a parseable config with none is no more inbound
1721
+ // than no config at all.
1722
+ const tick = resolveTickConfig(p.name);
1723
+ return tick.kind === "ok" && tick.config.accessFile !== undefined;
1724
+ },
1725
+ armSendTopic: (p) => resolveProjectTopicId(p),
1726
+ armScanDirs: (p) => {
1727
+ // The same session surface armTicks builds from the tick cwd and the
1728
+ // live claim's file: the cwd-derived session directory, plus the claim's
1729
+ // own directory when resume moved the transcript elsewhere (#600).
1730
+ const cwdDir = sessionDirForCwd(tickCwdForProject(p));
1731
+ const claimed = resolveClaimedSessionFile(p);
1732
+ if (claimed === undefined) return [cwdDir];
1733
+ const root = sessionsRoot();
1734
+ const claimDir = dirname(claimed);
1735
+ if (claimDir !== root && !claimDir.startsWith(join(root, sep))) return [cwdDir];
1736
+ return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
1737
+ },
1738
+ pidAlive,
1739
+ lockPidAlive,
1740
+ lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
1453
1741
  tickAgentName: (p) => {
1454
1742
  const tick = readTickConfig(tickCwdForProject(p));
1455
1743
  if (tick.kind !== "ok") return undefined;