omp-conductor 0.16.0 → 0.16.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.
- package/package.json +1 -1
- package/schema/config.schema.json +1 -0
- package/src/admission.ts +111 -7
- package/src/commands/tail.ts +204 -44
- package/src/config-schema.ts +4 -0
- package/src/config.ts +31 -1
- package/src/daemon.ts +16 -0
- package/src/depends-on.ts +77 -28
- package/src/doctor.ts +120 -1
- package/src/escalate.ts +77 -4
- package/src/fleet.ts +127 -42
- package/src/transcript.ts +1 -1
- package/src/worker.ts +196 -0
- package/src/worktree.ts +13 -1
package/src/depends-on.ts
CHANGED
|
@@ -1,34 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `Depends-on:` declaration parser (epic #321,
|
|
2
|
+
* The `Depends-on:` declaration parser (epic #321, slices #419/#420).
|
|
3
3
|
*
|
|
4
|
-
* A candidate issue can declare
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* A candidate issue can declare prerequisites it must not be dispatched
|
|
5
|
+
* before: `Depends-on: #123` (a same-repo issue) or `Depends-on:
|
|
6
|
+
* owner/repo#123` (an issue in a routed repository), repeatable one or more
|
|
7
|
+
* per line. Admission reads every referenced issue's live tracker state and
|
|
8
|
+
* holds the candidate while any prerequisite is open, so a worker is never
|
|
9
|
+
* sent at work whose base is not yet merged.
|
|
9
10
|
*
|
|
10
11
|
* The parser is deliberately small and strict, the two halves of the contract:
|
|
11
12
|
* the *marker* is matched case-insensitively (so `DEPENDS-ON`, `Depends-On`,
|
|
12
|
-
* `depends-on` all declare), while the *references* are strict bare issue
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `depends-on` all declare), while the *references* are strict — a bare issue
|
|
14
|
+
* number (`#123`) or a qualifying `owner/repo#123` with exactly two path
|
|
15
|
+
* segments glued to the number. A marker line that carries no strict
|
|
16
|
+
* reference — an empty `Depends-on:`, a bare non-numeric `Depends-on: #abc`,
|
|
17
|
+
* or a slashed-but-unqualified form that is not `owner/repo#n` — is never a
|
|
18
|
+
* usable prerequisite, so it is reported as `malformed` for the grooming flag
|
|
19
|
+
* rather than guessed at, and the declaration it belongs to is ignored.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
* graph cycles are later slices (#420/#421) and are deliberately out of scope
|
|
20
|
-
* here: only plain `#<n>` same-repo forms are resolved.
|
|
21
|
+
* Graph cycles are a later slice (#421) and stay out of scope here.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
export interface DependsOnDecl {
|
|
24
|
-
/** Referenced prerequisite issue numbers, deduplicated,
|
|
25
|
+
/** Referenced same-repo prerequisite issue numbers, deduplicated,
|
|
26
|
+
* first-seen order. */
|
|
25
27
|
readonly refs: readonly number[];
|
|
26
|
-
/**
|
|
28
|
+
/** Referenced cross-repo prerequisites (`owner/repo#n`), deduplicated,
|
|
29
|
+
* first-seen order. Admission resolves these against the fleet's routed
|
|
30
|
+
* repositories, never against the candidate's own repo (#420). */
|
|
31
|
+
readonly crossRefs: readonly DependsOnCrossRef[];
|
|
32
|
+
/** Marker-bearing lines that carried no strict reference (e.g.
|
|
27
33
|
* `Depends-on: #abc`). Bounded to what the body actually said, for the
|
|
28
34
|
* grooming flag's detail. */
|
|
29
35
|
readonly malformed: readonly string[];
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A strict cross-repo reference. `repo` is the canonical `owner/repo` spelling
|
|
40
|
+
* from the body, untouched, so admission can match it by identity against the
|
|
41
|
+
* fleet's routed repositories rather than by guess.
|
|
42
|
+
*/
|
|
43
|
+
export interface DependsOnCrossRef {
|
|
44
|
+
readonly repo: string;
|
|
45
|
+
readonly issue: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/**
|
|
33
49
|
* How a line is spelled: `depends-on`/`depends on`, optional whitespace, then
|
|
34
50
|
* a `:` or `=` separator. The marker is matched case-insensitively; the value
|
|
@@ -38,13 +54,29 @@ export interface DependsOnDecl {
|
|
|
38
54
|
*/
|
|
39
55
|
const MARKER = /^depends[-\s]?on\s*[:=]\s*(.*)$/i;
|
|
40
56
|
|
|
41
|
-
/** A strict reference is a bare `#` followed by digits — no repo
|
|
42
|
-
* no words.
|
|
43
|
-
|
|
57
|
+
/** A strict same-repo reference is a bare `#` followed by digits — no repo
|
|
58
|
+
* qualifier, no words. The negative lookbehind means a `#<n>` glued to a
|
|
59
|
+
* word (e.g. `web#7` or `issue#5`) is not a bare reference: without the
|
|
60
|
+
* `owner/` segment it is neither a valid cross-repo ref nor a valid
|
|
61
|
+
* same-repo one, so it is flagged malformed rather than silently resolved in
|
|
62
|
+
* the candidate's own repo. */
|
|
63
|
+
const REF = /(?<![A-Za-z0-9._/-])#(\d+)/g;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A strict cross-repo reference is `owner/repo#<n>` — exactly two path
|
|
67
|
+
* segments (each a GitHub owner/repository charset run) glued to a bare issue
|
|
68
|
+
* number, no whitespace. Scanned before {@link REF} and blanked out of the
|
|
69
|
+
* value, so the trailing `#<n>` is never re-read as a *same-repo* reference:
|
|
70
|
+
* the whole point of the qualified form is that it names a different
|
|
71
|
+
* repository, and resolving it in the candidate's own repo is the #420 fake.
|
|
72
|
+
*/
|
|
73
|
+
const CROSS_REF = /([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+)/g;
|
|
44
74
|
|
|
45
75
|
export function parseDependsOn(body: string): DependsOnDecl {
|
|
46
76
|
const refs: number[] = [];
|
|
47
77
|
const seen = new Set<number>();
|
|
78
|
+
const crossRefs: DependsOnCrossRef[] = [];
|
|
79
|
+
const seenCross = new Set<string>();
|
|
48
80
|
const malformed: string[] = [];
|
|
49
81
|
for (const raw of body.split("\n")) {
|
|
50
82
|
const line = raw.trim();
|
|
@@ -52,22 +84,39 @@ export function parseDependsOn(body: string): DependsOnDecl {
|
|
|
52
84
|
const match = MARKER.exec(line);
|
|
53
85
|
if (match === null) continue;
|
|
54
86
|
const value = match[1] ?? "";
|
|
87
|
+
|
|
88
|
+
// Cross-repo references first, blanked out of the value so their `#<n>`
|
|
89
|
+
// cannot also be collected as a same-repo reference below.
|
|
90
|
+
let lineHadRef = false;
|
|
91
|
+
const masked = value.replace(CROSS_REF, (whole, owner, name, number) => {
|
|
92
|
+
const repo = `${owner}/${name}`;
|
|
93
|
+
const n = Number(number);
|
|
94
|
+
if (Number.isSafeInteger(n) && !seenCross.has(`${repo}#${n}`)) {
|
|
95
|
+
seenCross.add(`${repo}#${n}`);
|
|
96
|
+
crossRefs.push({ repo, issue: n });
|
|
97
|
+
}
|
|
98
|
+
lineHadRef = true;
|
|
99
|
+
return " ".repeat(whole.length);
|
|
100
|
+
});
|
|
101
|
+
|
|
55
102
|
REF.lastIndex = 0;
|
|
56
|
-
const found = [...
|
|
57
|
-
if (found.length === 0) {
|
|
58
|
-
// A marker line with no strict reference is a malformed declaration:
|
|
59
|
-
// never crash, never silently claim — the declaration is ignored and
|
|
60
|
-
// surfaced for grooming.
|
|
61
|
-
malformed.push(line);
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
103
|
+
const found = [...masked.matchAll(REF)];
|
|
64
104
|
for (const group of found) {
|
|
65
105
|
const n = Number(group[1]!);
|
|
66
106
|
if (Number.isSafeInteger(n) && !seen.has(n)) {
|
|
67
107
|
seen.add(n);
|
|
68
108
|
refs.push(n);
|
|
69
109
|
}
|
|
110
|
+
lineHadRef = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!lineHadRef) {
|
|
114
|
+
// A marker line with no strict reference — same-repo or cross-repo — is
|
|
115
|
+
// a malformed declaration: never crash, never silently claim — the
|
|
116
|
+
// declaration is ignored and surfaced for grooming.
|
|
117
|
+
malformed.push(line);
|
|
118
|
+
continue;
|
|
70
119
|
}
|
|
71
120
|
}
|
|
72
|
-
return { refs, malformed };
|
|
121
|
+
return { refs, crossRefs, malformed };
|
|
73
122
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
} from "node:fs";
|
|
36
36
|
import { spawnSync } from "node:child_process";
|
|
37
37
|
import { homedir } from "node:os";
|
|
38
|
-
import { dirname, join } from "node:path";
|
|
38
|
+
import { dirname, join, sep } from "node:path";
|
|
39
39
|
import type { Stats } from "node:fs";
|
|
40
40
|
import { Database } from "bun:sqlite";
|
|
41
41
|
import { stringify } from "yaml";
|
|
@@ -54,9 +54,17 @@ import {
|
|
|
54
54
|
DEFAULT_HERDR_UNIT,
|
|
55
55
|
probeTelegramHealth,
|
|
56
56
|
resolveHerdrSessionWithBridge,
|
|
57
|
+
sessionsRoot,
|
|
58
|
+
sessionDirForCwd,
|
|
57
59
|
telegramStateDir,
|
|
58
60
|
} from "./fleet.ts";
|
|
59
61
|
import type { TelegramHealth } from "./status-render.ts";
|
|
62
|
+
import {
|
|
63
|
+
claimForProject,
|
|
64
|
+
claimedTelegramTopics,
|
|
65
|
+
telegramTopicsTidy,
|
|
66
|
+
type ClaimedTopic,
|
|
67
|
+
} from "./escalate.ts";
|
|
60
68
|
import {
|
|
61
69
|
herdrConductorPluginConfigDir,
|
|
62
70
|
planHostRuntime,
|
|
@@ -204,6 +212,10 @@ export interface DoctorDeps {
|
|
|
204
212
|
herdrConfig?: () => string | undefined;
|
|
205
213
|
/** The live herdr-conductor plugin `config.env`, or undefined. */
|
|
206
214
|
herdrEnv?: () => string | undefined;
|
|
215
|
+
/** Live omp-telegram topic claims (threads.json), for the #600 probes. */
|
|
216
|
+
claimedTopics?: () => ClaimedTopic[];
|
|
217
|
+
/** Whether the bridge's access.json runs per-session topic tidy on. */
|
|
218
|
+
topicsTidy?: () => boolean;
|
|
207
219
|
/** The fleet agent name the tick config of one project names, or undefined
|
|
208
220
|
* when there is no (readable) tick — the expected live herdr pane identity. */
|
|
209
221
|
tickAgentName?: (project: ProjectConfig) => string | undefined;
|
|
@@ -1068,6 +1080,103 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1068
1080
|
);
|
|
1069
1081
|
}
|
|
1070
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* #600 check — the orchestrator's claimed session file vs the directory arm
|
|
1085
|
+
* scans.
|
|
1086
|
+
*
|
|
1087
|
+
* A pane resumed from a session created elsewhere keeps the original transcript
|
|
1088
|
+
* path (herdr pins the pane to it), so the claimed file can live outside the
|
|
1089
|
+
* session directory the tick cwd implies. arm follows the claim, so the
|
|
1090
|
+
* mismatch alone is handled — but it is exactly the shape of host that once
|
|
1091
|
+
* made arming silently impossible, and an operator reading transcript paths
|
|
1092
|
+
* should not have to rediscover it. A claimed file *outside the session tree*
|
|
1093
|
+
* is worse: arm refuses to arm there (the reply could never be seen), so
|
|
1094
|
+
* doctor fails rather than letting the fleet sit disarmed.
|
|
1095
|
+
*/
|
|
1096
|
+
function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1097
|
+
const claims = probes.claimedTopics();
|
|
1098
|
+
if (claims.length === 0) {
|
|
1099
|
+
return passFinding(
|
|
1100
|
+
"arm-session-dir",
|
|
1101
|
+
`[${p.name}] no live omp-telegram claim — arm scans the tick-cwd session directory`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
const claim = claimForProject(claims, p.name);
|
|
1105
|
+
if (claim === undefined || claim.sessionFile === undefined) {
|
|
1106
|
+
return passFinding(
|
|
1107
|
+
"arm-session-dir",
|
|
1108
|
+
`[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const root = sessionsRoot();
|
|
1112
|
+
const claimDir = dirname(claim.sessionFile);
|
|
1113
|
+
const scanDir = sessionDirForCwd(tickCwdForProject(p));
|
|
1114
|
+
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1115
|
+
return failFinding(
|
|
1116
|
+
"arm-session-dir",
|
|
1117
|
+
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
|
|
1118
|
+
`arm cannot arm while the reply can only land there`,
|
|
1119
|
+
`start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (claimDir !== scanDir) {
|
|
1123
|
+
return warnFinding(
|
|
1124
|
+
"arm-session-dir",
|
|
1125
|
+
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
|
|
1126
|
+
`arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
|
|
1127
|
+
`optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return passFinding(
|
|
1131
|
+
"arm-session-dir",
|
|
1132
|
+
`[${p.name}] the orchestrator's claimed session file is inside the session directory arm scans`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* #600 check — a pinned escalation.telegramTopicId that no live claim carries.
|
|
1138
|
+
*
|
|
1139
|
+
* The wizard pins from the claims at setup time, but the bridge re-claims
|
|
1140
|
+
* 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.
|
|
1145
|
+
*/
|
|
1146
|
+
function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1147
|
+
const pinned = p.escalation.telegramTopicId;
|
|
1148
|
+
if (pinned === undefined) {
|
|
1149
|
+
return passFinding(
|
|
1150
|
+
"topic-pin",
|
|
1151
|
+
`[${p.name}] no pinned topic id — sends follow the live claim or the flat chat`,
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const claims = probes.claimedTopics();
|
|
1155
|
+
if (claims.length === 0) {
|
|
1156
|
+
return passFinding(
|
|
1157
|
+
"topic-pin",
|
|
1158
|
+
`[${p.name}] pinned topic ${pinned} — no live claims to compare (bridge state unreadable, or not a forum host)`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1162
|
+
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim`);
|
|
1163
|
+
}
|
|
1164
|
+
if (probes.topicsTidy()) {
|
|
1165
|
+
return warnFinding(
|
|
1166
|
+
"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`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
return warnFinding(
|
|
1173
|
+
"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`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1071
1180
|
/**
|
|
1072
1181
|
* #541 check 2 — `[session] resume_agents_on_restore` on the live herdr config.
|
|
1073
1182
|
*
|
|
@@ -1260,6 +1369,12 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1260
1369
|
findings.push(
|
|
1261
1370
|
passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
|
|
1262
1371
|
);
|
|
1372
|
+
findings.push(
|
|
1373
|
+
passFinding("arm-session-dir", projectProblem === undefined ? "no project resolved — nothing to check" : `arm session dir uncheckable: ${projectProblem}`),
|
|
1374
|
+
);
|
|
1375
|
+
findings.push(
|
|
1376
|
+
passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
|
|
1377
|
+
);
|
|
1263
1378
|
findings.push(
|
|
1264
1379
|
passFinding("omp-settings", projectProblem === undefined ? "no project resolved — nothing to check" : `omp settings overlay uncheckable: ${projectProblem}`),
|
|
1265
1380
|
);
|
|
@@ -1267,6 +1382,8 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1267
1382
|
for (const p of projects) {
|
|
1268
1383
|
findings.push(await labelProbe(probes, p));
|
|
1269
1384
|
findings.push(herdrAgentNameProbe(probes, p));
|
|
1385
|
+
findings.push(armSessionDirProbe(probes, p));
|
|
1386
|
+
findings.push(topicPinProbe(probes, p));
|
|
1270
1387
|
findings.push(ompSettingsProbe(p, sessionRootState));
|
|
1271
1388
|
}
|
|
1272
1389
|
}
|
|
@@ -1331,6 +1448,8 @@ export function defaultProbes(): Probes {
|
|
|
1331
1448
|
herdrSession: () => resolveHerdrSessionWithBridge(),
|
|
1332
1449
|
herdrConfig: defaultHerdrConfig,
|
|
1333
1450
|
herdrEnv: defaultHerdrEnv,
|
|
1451
|
+
claimedTopics: () => claimedTelegramTopics(),
|
|
1452
|
+
topicsTidy: () => telegramTopicsTidy(),
|
|
1334
1453
|
tickAgentName: (p) => {
|
|
1335
1454
|
const tick = readTickConfig(tickCwdForProject(p));
|
|
1336
1455
|
if (tick.kind !== "ok") return undefined;
|
package/src/escalate.ts
CHANGED
|
@@ -436,6 +436,13 @@ export interface ClaimedTopic {
|
|
|
436
436
|
name: string;
|
|
437
437
|
/** The herdr space the claiming pane sits in, when the bridge captured one. */
|
|
438
438
|
workspaceLabel?: string;
|
|
439
|
+
/**
|
|
440
|
+
* The transcript the claiming pane actually writes, when the bridge captured
|
|
441
|
+
* one. This is the *live* session file — herdr pins a restored pane to it
|
|
442
|
+
* (`omp --resume=<that path>`) — so it can sit in a different session
|
|
443
|
+
* directory than the pane's cwd implies (#600).
|
|
444
|
+
*/
|
|
445
|
+
sessionFile?: string;
|
|
439
446
|
}
|
|
440
447
|
|
|
441
448
|
/**
|
|
@@ -470,17 +477,47 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
|
|
|
470
477
|
const claims = claimedTelegramTopics();
|
|
471
478
|
if (claims.length === 0) return pinned;
|
|
472
479
|
if (claims.some((claim) => claim.threadId === pinned)) return pinned;
|
|
473
|
-
const
|
|
474
|
-
const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
|
|
480
|
+
const match = claimForProject(claims, project.name);
|
|
475
481
|
if (match === undefined) return pinned;
|
|
476
482
|
warn(
|
|
477
483
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
478
484
|
`following omp-telegram's current "${project.name}" ` +
|
|
479
|
-
`${
|
|
485
|
+
`${match.workspaceLabel === project.name ? "herdr space claim" : "claim"} instead`,
|
|
480
486
|
);
|
|
481
487
|
return match.threadId;
|
|
482
488
|
}
|
|
483
489
|
|
|
490
|
+
/**
|
|
491
|
+
* The one claim answering to a project's identity — herdr space first, title
|
|
492
|
+
* second — or nothing when none or several do (#412).
|
|
493
|
+
*/
|
|
494
|
+
export function claimForProject(
|
|
495
|
+
claims: readonly ClaimedTopic[],
|
|
496
|
+
projectName: string,
|
|
497
|
+
): ClaimedTopic | undefined {
|
|
498
|
+
const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === projectName);
|
|
499
|
+
return bySpace ?? soleClaim(claims, (claim) => claim.name === projectName);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The transcript this project's live omp-telegram claim names — where the
|
|
504
|
+
* orchestrator pane's reply actually lands.
|
|
505
|
+
*
|
|
506
|
+
* `arm` derives its scan directory from the tick cwd, but a pane resumed from
|
|
507
|
+
* a session created elsewhere keeps the original transcript path (herdr pins
|
|
508
|
+
* the pane to it), so the cwd-derived directory is the one place the reply is
|
|
509
|
+
* guaranteed *not* to be (#600). The claim's `sessionFile` is the live answer;
|
|
510
|
+
* identity resolves exactly as {@link resolveProjectTopicId}'s substitution
|
|
511
|
+
* does, via {@link claimForProject}. Absent when the bridge has no claim or
|
|
512
|
+
* the claim names no file — the caller then falls back to the cwd-derived
|
|
513
|
+
* directory, which is the honest answer for a host that predates claims.
|
|
514
|
+
*/
|
|
515
|
+
export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
|
|
516
|
+
const claims = claimedTelegramTopics();
|
|
517
|
+
if (claims.length === 0) return undefined;
|
|
518
|
+
return claimForProject(claims, project.name)?.sessionFile;
|
|
519
|
+
}
|
|
520
|
+
|
|
484
521
|
/**
|
|
485
522
|
* The one claim answering to `predicate`, or nothing when none or several do.
|
|
486
523
|
*
|
|
@@ -523,6 +560,7 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
523
560
|
if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
|
|
524
561
|
let name = id;
|
|
525
562
|
let workspaceLabel: string | undefined;
|
|
563
|
+
let sessionFile: string | undefined;
|
|
526
564
|
if (typeof entry === "object" && entry !== null) {
|
|
527
565
|
if ("name" in entry) {
|
|
528
566
|
const candidate = entry.name;
|
|
@@ -532,12 +570,47 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
532
570
|
const candidate = entry.workspaceLabel;
|
|
533
571
|
if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
|
|
534
572
|
}
|
|
573
|
+
if ("sessionFile" in entry) {
|
|
574
|
+
const candidate = entry.sessionFile;
|
|
575
|
+
if (typeof candidate === "string" && candidate.trim() !== "") sessionFile = candidate.trim();
|
|
576
|
+
}
|
|
535
577
|
}
|
|
536
|
-
out.push(
|
|
578
|
+
out.push(
|
|
579
|
+
workspaceLabel === undefined && sessionFile === undefined
|
|
580
|
+
? { threadId, name }
|
|
581
|
+
: workspaceLabel === undefined
|
|
582
|
+
? { threadId, name, sessionFile }
|
|
583
|
+
: sessionFile === undefined
|
|
584
|
+
? { threadId, name, workspaceLabel }
|
|
585
|
+
: { threadId, name, workspaceLabel, sessionFile },
|
|
586
|
+
);
|
|
537
587
|
}
|
|
538
588
|
return out;
|
|
539
589
|
}
|
|
540
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Whether the paired bridge runs with per-session topic tidy on (access.json
|
|
593
|
+
* `topicsTidy: true` — `/telegram topics tidy on`).
|
|
594
|
+
*
|
|
595
|
+
* On a tidy host every pane exit closes (forum) or deletes (DM) its own topic,
|
|
596
|
+
* so a pinned `escalation.telegramTopicId` is structurally stale: it can never
|
|
597
|
+
* be among the live claims again, and treating it as a live pin is the false
|
|
598
|
+
* belief #600 names. The bridge's own setting is read here so `doctor` can say
|
|
599
|
+
* "vestigial" rather than "moved once".
|
|
600
|
+
*/
|
|
601
|
+
export function telegramTopicsTidy(stateDirPath?: string): boolean {
|
|
602
|
+
const override = stateDirPath?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
603
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
604
|
+
try {
|
|
605
|
+
const parsed = JSON.parse(readFileSync(join(dir, "access.json"), "utf8")) as {
|
|
606
|
+
readonly topicsTidy?: unknown;
|
|
607
|
+
};
|
|
608
|
+
return parsed.topicsTidy === true;
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
541
614
|
/**
|
|
542
615
|
* The one Telegram send in this package. Exported so the report outbox (#123)
|
|
543
616
|
* reuses it rather than forking it: the response handling below is load-bearing
|