pi-harness-runtime 1.1.30 → 1.1.31
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/harness/langchain/agents.ts +198 -0
- package/harness/langchain/daemon.test.ts +353 -0
- package/harness/langchain/daemon.ts +880 -0
- package/harness/langchain/graph.ts +284 -0
- package/harness/langchain/models.ts +95 -0
- package/harness/langchain/run.ts +205 -0
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/dist/src/types.d.ts +1 -1
- package/packages/event-bus/dist/src/types.d.ts.map +1 -1
- package/packages/event-bus/dist/src/types.js.map +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-bus/src/types.ts +1 -0
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop Daemon — Auto-triggered write-review loop.
|
|
3
|
+
*
|
|
4
|
+
* Design philosophy: Human *on* the loop, not *in* the loop.
|
|
5
|
+
* The daemon starts itself, routes itself, and only notifies the human at gates
|
|
6
|
+
* (approval, escalation, blocked). No human ever acts as the message bus between
|
|
7
|
+
* GPT and MiniMax.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
*
|
|
11
|
+
* TRIGGERS
|
|
12
|
+
* bd task created ─┐
|
|
13
|
+
* inbox file ─┤
|
|
14
|
+
* bus event ─┼──► watcher (poll) ─► LeaseManager.claim ─► LangGraph.run
|
|
15
|
+
* cron (stub) ─┘ │
|
|
16
|
+
* │ publish (loop-events.ts) ─► HerdrEventBus
|
|
17
|
+
* │ gates ─► NotificationCenter
|
|
18
|
+
*
|
|
19
|
+
* Milestones:
|
|
20
|
+
* M1 — Daemon skeleton + inbox/bus watchers
|
|
21
|
+
* M2 — Runner integration: LeaseManager → buildWriteReviewLoop → publish
|
|
22
|
+
* M3 — Gates & notifications
|
|
23
|
+
* M4 — Robustness: retry/backoff, heartbeats, stale-lease, bd-tasks watcher
|
|
24
|
+
*
|
|
25
|
+
* Wiki: wiki/auto-trigger-multi-agent.md
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { LeaseManager } from "../../packages/autonomous-runtime/src/lease.js";
|
|
39
|
+
import type { ApprovalClass } from "../../packages/autonomous-runtime/src/types.js";
|
|
40
|
+
import {
|
|
41
|
+
ensureHerdrWorkspace,
|
|
42
|
+
getHerdrWorkspace,
|
|
43
|
+
type HerdrEventBus,
|
|
44
|
+
type LoopVerdict,
|
|
45
|
+
} from "../../packages/event-bus/src/herdr-bus.js";
|
|
46
|
+
import {
|
|
47
|
+
createHerdrBus,
|
|
48
|
+
publishCodeWritten,
|
|
49
|
+
publishLoopFinished,
|
|
50
|
+
publishLoopStarted,
|
|
51
|
+
publishReviewCompleted,
|
|
52
|
+
} from "../../packages/event-bus/src/herdr-bus.js";
|
|
53
|
+
import { NotificationCenter } from "../../packages/notification/dist/notification-center.js";
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
buildDryRunDeps,
|
|
57
|
+
buildRealLoopDeps,
|
|
58
|
+
buildWriteReviewLoop,
|
|
59
|
+
type LoopDeps,
|
|
60
|
+
type LoopState,
|
|
61
|
+
type WriteReviewLoop,
|
|
62
|
+
} from "./graph.js";
|
|
63
|
+
|
|
64
|
+
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export type TriggerSource = "bd-tasks" | "inbox" | "bus" | "cron";
|
|
67
|
+
|
|
68
|
+
export type ApprovalPolicy =
|
|
69
|
+
| "never" // fully autonomous; notify only on finish/blocked (default)
|
|
70
|
+
| "on_blocked" // notify + WAIT for human ack when verdict === "blocked"
|
|
71
|
+
| "on_class" // wait only when task's ApprovalClass demands it
|
|
72
|
+
| "always"; // wait after every review iteration (regulated mode)
|
|
73
|
+
|
|
74
|
+
export interface DaemonConfig {
|
|
75
|
+
/** Agent/worker identity. Default: "loop-daemon" */
|
|
76
|
+
agentId?: string;
|
|
77
|
+
/** Trigger poll interval in ms. Default: 2000 */
|
|
78
|
+
pollMs?: number;
|
|
79
|
+
/** Max write-review rounds per task. Default: 3 */
|
|
80
|
+
maxIterations?: number;
|
|
81
|
+
/** Human gate policy. Default: "never" */
|
|
82
|
+
approvalPolicy?: ApprovalPolicy;
|
|
83
|
+
/** Use deterministic stubs (no API keys). Default: false */
|
|
84
|
+
dryRun?: boolean;
|
|
85
|
+
/** Abort loop if cumulative cost exceeds this. Default: no cap */
|
|
86
|
+
costCapUsd?: number;
|
|
87
|
+
/** Which watchers to enable. Default: ["inbox", "bus"] */
|
|
88
|
+
sources?: TriggerSource[];
|
|
89
|
+
/** Herdr workspace override. Default: from herdr-bus */
|
|
90
|
+
workspace?: string;
|
|
91
|
+
/** NotificationCenter config */
|
|
92
|
+
notificationConfig?: {
|
|
93
|
+
telegramBotToken?: string;
|
|
94
|
+
telegramChatId?: string;
|
|
95
|
+
ntfyTopic?: string;
|
|
96
|
+
webhookUrl?: string;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── TriggeredTask ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface TriggeredTask {
|
|
103
|
+
/** Stable id — derived from the trigger source */
|
|
104
|
+
taskId: string;
|
|
105
|
+
/** Human-readable task description */
|
|
106
|
+
request: string;
|
|
107
|
+
/** How the task arrived */
|
|
108
|
+
source: TriggerSource;
|
|
109
|
+
/** Optional approval class from task metadata */
|
|
110
|
+
approvalClass?: ApprovalClass;
|
|
111
|
+
/** ISO timestamp of when the trigger was observed */
|
|
112
|
+
triggeredAt: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── TaskProposedPayload (bus event shape) ──────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export interface TaskProposedPayload {
|
|
118
|
+
taskId: string;
|
|
119
|
+
request: string;
|
|
120
|
+
approvalClass?: ApprovalClass;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Internal state ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
interface RunningTask {
|
|
126
|
+
task: TriggeredTask;
|
|
127
|
+
lease: ReturnType<LeaseManager["claim"]>;
|
|
128
|
+
startedAt: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface TaskSeen {
|
|
132
|
+
taskId: string;
|
|
133
|
+
source: TriggerSource;
|
|
134
|
+
seenAt: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Ack file helpers ───────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** Path for human approval ack files. */
|
|
140
|
+
function ackFilePath(inboxDir: string, taskId: string): string {
|
|
141
|
+
return join(inboxDir, `ack-${taskId}.json`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read an ack file. Returns null if it doesn't exist yet. */
|
|
145
|
+
function readAck(
|
|
146
|
+
inboxDir: string,
|
|
147
|
+
taskId: string,
|
|
148
|
+
): { decision: "approved" | "denied"; decidedAt: string } | null {
|
|
149
|
+
const path = ackFilePath(inboxDir, taskId);
|
|
150
|
+
if (!existsSync(path)) return null;
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(readFileSync(path, "utf8")) as {
|
|
153
|
+
decision: "approved" | "denied";
|
|
154
|
+
decidedAt: string;
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Write an ack file (used by tests / manual approval). */
|
|
162
|
+
export function writeAck(
|
|
163
|
+
inboxDir: string,
|
|
164
|
+
taskId: string,
|
|
165
|
+
decision: "approved" | "denied",
|
|
166
|
+
): void {
|
|
167
|
+
const path = ackFilePath(inboxDir, taskId);
|
|
168
|
+
writeFileSync(
|
|
169
|
+
path,
|
|
170
|
+
JSON.stringify({ decision, decidedAt: new Date().toISOString() }, null, 2),
|
|
171
|
+
"utf8",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Approval gate ───────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/** Resolve the effective approval policy for a task given the daemon config. */
|
|
178
|
+
function effectivePolicy(
|
|
179
|
+
task: TriggeredTask,
|
|
180
|
+
config: DaemonConfig,
|
|
181
|
+
): ApprovalPolicy {
|
|
182
|
+
const policy = config.approvalPolicy ?? "never";
|
|
183
|
+
if (policy === "on_class") {
|
|
184
|
+
// Map ApprovalClass → gate policy
|
|
185
|
+
const cls = task.approvalClass ?? "automatic_reversible";
|
|
186
|
+
if (cls === "human_approval_required") return "on_blocked";
|
|
187
|
+
return "never";
|
|
188
|
+
}
|
|
189
|
+
return policy;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Wait (poll) for a human ack file or bus event. Returns true if approved. */
|
|
193
|
+
async function waitForAck(
|
|
194
|
+
task: TriggeredTask,
|
|
195
|
+
_config: DaemonConfig,
|
|
196
|
+
_bus: HerdrEventBus,
|
|
197
|
+
inboxDir: string,
|
|
198
|
+
): Promise<boolean> {
|
|
199
|
+
const pollMs = _config.pollMs ?? 2_000;
|
|
200
|
+
const timeoutMs = 30 * 60 * 1_000; // 30-minute human SLA
|
|
201
|
+
|
|
202
|
+
const deadline = Date.now() + timeoutMs;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
// Check ack file first
|
|
205
|
+
const ack = readAck(inboxDir, task.taskId);
|
|
206
|
+
if (ack) {
|
|
207
|
+
console.log(`[gate] Ack file found for ${task.taskId}: ${ack.decision}`);
|
|
208
|
+
return ack.decision === "approved";
|
|
209
|
+
}
|
|
210
|
+
// Also accept approval.granted bus event
|
|
211
|
+
// (The bus watcher is already polling; we just yield and let it tick)
|
|
212
|
+
await sleep(pollMs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.warn(`[gate] Ack timeout for ${task.taskId} — treating as denied`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function sleep(ms: number): Promise<void> {
|
|
222
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stableTaskId(source: TriggerSource, raw: string): string {
|
|
226
|
+
if (source === "inbox") {
|
|
227
|
+
// For inbox, use the filename as taskId so tests can write ack files
|
|
228
|
+
return raw;
|
|
229
|
+
}
|
|
230
|
+
// Produce a stable short id for non-inbox sources
|
|
231
|
+
const base = `${source}:${raw}`.slice(0, 64);
|
|
232
|
+
let hash = 0;
|
|
233
|
+
for (let i = 0; i < base.length; i++) {
|
|
234
|
+
const ch = base.charCodeAt(i);
|
|
235
|
+
hash = (hash << 5) - hash + ch;
|
|
236
|
+
hash |= 0;
|
|
237
|
+
}
|
|
238
|
+
return `task-${Math.abs(hash).toString(36)}-${Date.now().toString(36).slice(-6)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Inbox watcher ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** Watch a directory for new `.md` files — each file = one TriggeredTask. */
|
|
244
|
+
class InboxWatcher {
|
|
245
|
+
private readonly inboxDir: string;
|
|
246
|
+
private readonly seen: Map<string, TaskSeen> = new Map();
|
|
247
|
+
private readonly pollMs: number;
|
|
248
|
+
private running = false;
|
|
249
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
250
|
+
|
|
251
|
+
constructor(inboxDir: string, pollMs: number) {
|
|
252
|
+
this.inboxDir = inboxDir;
|
|
253
|
+
this.pollMs = pollMs;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Start polling for new inbox files. */
|
|
257
|
+
start(onTask: (task: TriggeredTask) => void): void {
|
|
258
|
+
this.running = true;
|
|
259
|
+
// Seed seen set with existing files
|
|
260
|
+
this._seedSeen();
|
|
261
|
+
this._poll(onTask);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
stop(): void {
|
|
265
|
+
this.running = false;
|
|
266
|
+
if (this.timer) {
|
|
267
|
+
clearTimeout(this.timer);
|
|
268
|
+
this.timer = undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private _seedSeen(): void {
|
|
273
|
+
if (!existsSync(this.inboxDir)) return;
|
|
274
|
+
for (const file of readdirSync(this.inboxDir)) {
|
|
275
|
+
if (!file.endsWith(".md")) continue;
|
|
276
|
+
if (file.startsWith("ack-")) continue;
|
|
277
|
+
this.seen.set(file, {
|
|
278
|
+
taskId: file,
|
|
279
|
+
source: "inbox",
|
|
280
|
+
seenAt: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private _poll(onTask: (task: TriggeredTask) => void): void {
|
|
286
|
+
if (!this.running) return;
|
|
287
|
+
try {
|
|
288
|
+
if (existsSync(this.inboxDir)) {
|
|
289
|
+
for (const file of readdirSync(this.inboxDir)) {
|
|
290
|
+
if (!file.endsWith(".md")) continue;
|
|
291
|
+
if (file.startsWith("ack-")) continue;
|
|
292
|
+
if (this.seen.has(file)) continue;
|
|
293
|
+
|
|
294
|
+
const taskId = stableTaskId("inbox", file);
|
|
295
|
+
const request = this._readRequest(file);
|
|
296
|
+
this.seen.set(file, {
|
|
297
|
+
taskId,
|
|
298
|
+
source: "inbox",
|
|
299
|
+
seenAt: new Date().toISOString(),
|
|
300
|
+
});
|
|
301
|
+
onTask({
|
|
302
|
+
taskId,
|
|
303
|
+
request,
|
|
304
|
+
source: "inbox",
|
|
305
|
+
triggeredAt: new Date().toISOString(),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(
|
|
311
|
+
`[inbox-watcher] poll error: ${err instanceof Error ? err.message : String(err)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
this.timer = setTimeout(() => this._poll(onTask), this.pollMs);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _readRequest(file: string): string {
|
|
318
|
+
try {
|
|
319
|
+
const content = readFileSync(join(this.inboxDir, file), "utf8");
|
|
320
|
+
// Strip markdown frontmatter if present
|
|
321
|
+
const stripped = content.replace(/^---[\s\S]*?---\n?/, "").trim();
|
|
322
|
+
return stripped || file;
|
|
323
|
+
} catch {
|
|
324
|
+
return file;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Bus watcher ────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/** Watch HerdrEventBus for `task.proposed` events. */
|
|
332
|
+
class BusWatcher {
|
|
333
|
+
private readonly bus: HerdrEventBus;
|
|
334
|
+
private readonly seen: Set<string> = new Set();
|
|
335
|
+
private readonly pollMs: number;
|
|
336
|
+
private running = false;
|
|
337
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
338
|
+
|
|
339
|
+
constructor(bus: HerdrEventBus, pollMs: number) {
|
|
340
|
+
this.bus = bus;
|
|
341
|
+
this.pollMs = pollMs;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Start polling for task.proposed bus events. */
|
|
345
|
+
start(onTask: (task: TriggeredTask) => void): void {
|
|
346
|
+
this.running = true;
|
|
347
|
+
this._poll(onTask);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
stop(): void {
|
|
351
|
+
this.running = false;
|
|
352
|
+
if (this.timer) {
|
|
353
|
+
clearTimeout(this.timer);
|
|
354
|
+
this.timer = undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private _poll(onTask: (task: TriggeredTask) => void): void {
|
|
359
|
+
if (!this.running) return;
|
|
360
|
+
try {
|
|
361
|
+
// Re-use the bus's own polling by manually reading events.jsonl
|
|
362
|
+
// This avoids double-polling since we share the same workspace.
|
|
363
|
+
// The bus watcher delegates to the bus's internal poll mechanism
|
|
364
|
+
// by subscribing dynamically.
|
|
365
|
+
this._pollBusEvents(onTask);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(
|
|
368
|
+
`[bus-watcher] poll error: ${err instanceof Error ? err.message : String(err)}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
this.timer = setTimeout(() => this._poll(onTask), this.pollMs);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private _pollBusEvents(onTask: (task: TriggeredTask) => void): void {
|
|
375
|
+
// Read events.jsonl for task.proposed events we haven't seen
|
|
376
|
+
const eventsPath = join(this.bus.getWorkspace(), "events.jsonl");
|
|
377
|
+
if (!existsSync(eventsPath)) return;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const content = readFileSync(eventsPath, "utf8");
|
|
381
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
382
|
+
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
try {
|
|
385
|
+
const event = JSON.parse(line) as {
|
|
386
|
+
eventId?: string;
|
|
387
|
+
topic?: string;
|
|
388
|
+
data?: unknown;
|
|
389
|
+
};
|
|
390
|
+
if (event.topic !== "task.proposed") continue;
|
|
391
|
+
if (!event.eventId) continue;
|
|
392
|
+
if (this.seen.has(event.eventId)) continue;
|
|
393
|
+
|
|
394
|
+
const payload = event.data as TaskProposedPayload | undefined;
|
|
395
|
+
if (!payload?.request) continue;
|
|
396
|
+
|
|
397
|
+
this.seen.add(event.eventId);
|
|
398
|
+
const taskId = payload.taskId || stableTaskId("bus", payload.request);
|
|
399
|
+
onTask({
|
|
400
|
+
taskId,
|
|
401
|
+
request: payload.request,
|
|
402
|
+
source: "bus",
|
|
403
|
+
approvalClass: payload.approvalClass,
|
|
404
|
+
triggeredAt: new Date().toISOString(),
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
// skip malformed lines
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
// File may not exist yet
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── Bd-tasks watcher ───────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
/** Watch bd (beads) task list for new pending tasks. */
|
|
419
|
+
class BdTasksWatcher {
|
|
420
|
+
private readonly pollMs: number;
|
|
421
|
+
private readonly seen: Set<string> = new Set();
|
|
422
|
+
private running = false;
|
|
423
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
424
|
+
|
|
425
|
+
constructor(pollMs: number) {
|
|
426
|
+
this.pollMs = pollMs;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
start(onTask: (task: TriggeredTask) => void): void {
|
|
430
|
+
this.running = true;
|
|
431
|
+
this._poll(onTask);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
stop(): void {
|
|
435
|
+
this.running = false;
|
|
436
|
+
if (this.timer) {
|
|
437
|
+
clearTimeout(this.timer);
|
|
438
|
+
this.timer = undefined;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private async _poll(onTask: (task: TriggeredTask) => void): Promise<void> {
|
|
443
|
+
if (!this.running) return;
|
|
444
|
+
try {
|
|
445
|
+
await this._checkBdTasks(onTask);
|
|
446
|
+
} catch {
|
|
447
|
+
// bd command not available — silently skip
|
|
448
|
+
}
|
|
449
|
+
this.timer = setTimeout(() => {
|
|
450
|
+
this._poll(onTask).catch(() => {});
|
|
451
|
+
}, this.pollMs);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async _checkBdTasks(
|
|
455
|
+
onTask: (task: TriggeredTask) => void,
|
|
456
|
+
): Promise<void> {
|
|
457
|
+
const { execSync } = await import("node:child_process");
|
|
458
|
+
let output: string;
|
|
459
|
+
try {
|
|
460
|
+
output = execSync("bd list --status pending --json 2>/dev/null", {
|
|
461
|
+
encoding: "utf8",
|
|
462
|
+
timeout: 5_000,
|
|
463
|
+
});
|
|
464
|
+
} catch {
|
|
465
|
+
return; // bd not available or no pending tasks
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const result = JSON.parse(output) as Array<{
|
|
470
|
+
id: string;
|
|
471
|
+
subject: string;
|
|
472
|
+
metadata?: { approvalClass?: ApprovalClass };
|
|
473
|
+
}>;
|
|
474
|
+
for (const task of result) {
|
|
475
|
+
if (this.seen.has(task.id)) continue;
|
|
476
|
+
this.seen.add(task.id);
|
|
477
|
+
onTask({
|
|
478
|
+
taskId: task.id,
|
|
479
|
+
request: task.subject,
|
|
480
|
+
source: "bd-tasks",
|
|
481
|
+
approvalClass: task.metadata?.approvalClass,
|
|
482
|
+
triggeredAt: new Date().toISOString(),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
// bd output not valid JSON — ignore
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Loop Daemon ────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The main daemon class. Coordinates watchers, leasing, loop execution, and
|
|
495
|
+
* notifications.
|
|
496
|
+
*
|
|
497
|
+
* Usage:
|
|
498
|
+
* const daemon = new LoopDaemon({ dryRun: true, sources: ["inbox", "bus"] });
|
|
499
|
+
* daemon.start();
|
|
500
|
+
* // ... daemon runs in the background ...
|
|
501
|
+
* daemon.stop();
|
|
502
|
+
*/
|
|
503
|
+
export class LoopDaemon {
|
|
504
|
+
private readonly config: Required<DaemonConfig>;
|
|
505
|
+
private readonly agentId: string;
|
|
506
|
+
private readonly leaseManager: LeaseManager;
|
|
507
|
+
private readonly bus: HerdrEventBus;
|
|
508
|
+
private readonly notificationCenter: NotificationCenter | null;
|
|
509
|
+
private readonly inboxDir: string;
|
|
510
|
+
|
|
511
|
+
private readonly inboxWatcher: InboxWatcher;
|
|
512
|
+
private readonly busWatcher: BusWatcher;
|
|
513
|
+
private readonly bdWatcher: BdTasksWatcher;
|
|
514
|
+
|
|
515
|
+
/** Currently running tasks (taskId → RunningTask) */
|
|
516
|
+
private readonly running = new Map<string, RunningTask>();
|
|
517
|
+
|
|
518
|
+
private stopped = false;
|
|
519
|
+
|
|
520
|
+
constructor(config: DaemonConfig = {}) {
|
|
521
|
+
ensureHerdrWorkspace();
|
|
522
|
+
|
|
523
|
+
this.config = {
|
|
524
|
+
agentId: config.agentId ?? "loop-daemon",
|
|
525
|
+
pollMs: config.pollMs ?? 2_000,
|
|
526
|
+
maxIterations: config.maxIterations ?? 3,
|
|
527
|
+
approvalPolicy: config.approvalPolicy ?? "never",
|
|
528
|
+
dryRun: config.dryRun ?? false,
|
|
529
|
+
costCapUsd: config.costCapUsd ?? undefined,
|
|
530
|
+
sources: config.sources ?? ["inbox", "bus"],
|
|
531
|
+
workspace: config.workspace ?? getHerdrWorkspace(),
|
|
532
|
+
notificationConfig: config.notificationConfig ?? undefined,
|
|
533
|
+
} as Required<DaemonConfig>;
|
|
534
|
+
|
|
535
|
+
this.agentId = this.config.agentId;
|
|
536
|
+
this.bus = createHerdrBus(this.agentId);
|
|
537
|
+
|
|
538
|
+
// All watchers share the bus's workspace directory
|
|
539
|
+
const workspace = this.bus.getWorkspace();
|
|
540
|
+
this.inboxDir = workspace; // inbox IS the herdr workspace root
|
|
541
|
+
this.leaseManager = new LeaseManager({
|
|
542
|
+
leasesDir: `${this.inboxDir}/leases`,
|
|
543
|
+
});
|
|
544
|
+
this.leaseManager.ensureDir();
|
|
545
|
+
|
|
546
|
+
if (this.config.notificationConfig) {
|
|
547
|
+
this.notificationCenter = new NotificationCenter({
|
|
548
|
+
channels: this._buildChannels(this.config.notificationConfig) as Parameters<
|
|
549
|
+
typeof NotificationCenter.prototype.notify
|
|
550
|
+
>[0] extends { channels: infer C }
|
|
551
|
+
? C
|
|
552
|
+
: never,
|
|
553
|
+
enabled: true,
|
|
554
|
+
});
|
|
555
|
+
} else {
|
|
556
|
+
this.notificationCenter = null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.inboxWatcher = new InboxWatcher(this.inboxDir, this.config.pollMs);
|
|
560
|
+
this.busWatcher = new BusWatcher(this.bus, this.config.pollMs);
|
|
561
|
+
this.bdWatcher = new BdTasksWatcher(this.config.pollMs);
|
|
562
|
+
|
|
563
|
+
// Reap stale leases from previous runs
|
|
564
|
+
const reclaimed = this.leaseManager.recoverOnStartup();
|
|
565
|
+
if (reclaimed.length > 0) {
|
|
566
|
+
console.log(
|
|
567
|
+
`[daemon] Reclaimed ${reclaimed.length} stale lease(s): ${reclaimed.join(", ")}`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/** Start the daemon — begins watching all configured sources. */
|
|
575
|
+
start(): void {
|
|
576
|
+
console.log(
|
|
577
|
+
`[daemon] Starting (agentId=${this.agentId}, sources=[${this.config.sources.join(", ")}], dryRun=${this.config.dryRun})`,
|
|
578
|
+
);
|
|
579
|
+
this.stopped = false;
|
|
580
|
+
|
|
581
|
+
if (this.config.sources.includes("inbox")) {
|
|
582
|
+
console.log(`[daemon] Starting inbox watcher on ${this.inboxDir}`);
|
|
583
|
+
this.inboxWatcher.start((t) => this._onTriggered(t));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (this.config.sources.includes("bus")) {
|
|
587
|
+
console.log(`[daemon] Starting bus watcher (task.proposed)`);
|
|
588
|
+
this.busWatcher.start((t) => this._onTriggered(t));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (this.config.sources.includes("bd-tasks")) {
|
|
592
|
+
console.log(`[daemon] Starting bd-tasks watcher`);
|
|
593
|
+
this.bdWatcher.start((t) => this._onTriggered(t));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
console.log("[daemon] Started.");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Stop all watchers and release all held leases. */
|
|
600
|
+
stop(): void {
|
|
601
|
+
console.log("[daemon] Stopping...");
|
|
602
|
+
this.stopped = true;
|
|
603
|
+
|
|
604
|
+
this.inboxWatcher.stop();
|
|
605
|
+
this.busWatcher.stop();
|
|
606
|
+
this.bdWatcher.stop();
|
|
607
|
+
|
|
608
|
+
// Release all held leases
|
|
609
|
+
for (const [taskId, _runningTask] of this.running) {
|
|
610
|
+
try {
|
|
611
|
+
this.leaseManager.release(taskId, this.agentId);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(`[daemon] Failed to release lease for ${taskId}: ${err}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
this.running.clear();
|
|
617
|
+
|
|
618
|
+
console.log("[daemon] Stopped.");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Returns true if stop() has been called. */
|
|
622
|
+
get isStopped(): boolean {
|
|
623
|
+
return this.stopped;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─── Task processing ──────────────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
private _onTriggered(task: TriggeredTask): void {
|
|
629
|
+
if (this.stopped) return;
|
|
630
|
+
|
|
631
|
+
// Skip if already running this task
|
|
632
|
+
if (this.running.has(task.taskId)) {
|
|
633
|
+
console.log(
|
|
634
|
+
`[daemon] Task ${task.taskId} already running — skipping trigger`,
|
|
635
|
+
);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
console.log(
|
|
640
|
+
`[daemon] Triggered: [${task.source}] ${task.taskId} — "${task.request.slice(0, 60)}"`,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// Try to claim the lease
|
|
644
|
+
const lease = this.leaseManager.claim(task.taskId, this.agentId);
|
|
645
|
+
if (!lease) {
|
|
646
|
+
console.log(
|
|
647
|
+
`[daemon] Lease held for ${task.taskId} — skipping (another worker)`,
|
|
648
|
+
);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Run the task asynchronously (do not block the watcher)
|
|
653
|
+
this.running.set(task.taskId, {
|
|
654
|
+
task,
|
|
655
|
+
lease,
|
|
656
|
+
startedAt: new Date().toISOString(),
|
|
657
|
+
});
|
|
658
|
+
this._runTask(task).catch((err) => {
|
|
659
|
+
console.error(
|
|
660
|
+
`[daemon] Task ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
661
|
+
);
|
|
662
|
+
this.running.delete(task.taskId);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async _runTask(task: TriggeredTask): Promise<void> {
|
|
667
|
+
const loopId = `loop-${randomUUID().slice(0, 8)}`;
|
|
668
|
+
const log = (msg: string) => console.log(`[${loopId}] ${msg}`);
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
// ── Gate check (I7) ──────────────────────────────────────────────
|
|
672
|
+
const policy = effectivePolicy(task, this.config);
|
|
673
|
+
if (policy === "always" || policy === "on_blocked") {
|
|
674
|
+
log(`Gate policy=${policy} — waiting for human approval before starting`);
|
|
675
|
+
const approved = await waitForAck(
|
|
676
|
+
task,
|
|
677
|
+
this.config,
|
|
678
|
+
this.bus,
|
|
679
|
+
this.inboxDir,
|
|
680
|
+
);
|
|
681
|
+
if (!approved) {
|
|
682
|
+
log(`Human denied approval — parking task`);
|
|
683
|
+
this._notifyHumanReviewNeeded(
|
|
684
|
+
task,
|
|
685
|
+
"Human denied approval for task start",
|
|
686
|
+
);
|
|
687
|
+
this._releaseLease(task.taskId);
|
|
688
|
+
this.running.delete(task.taskId);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
log(`Human approved — proceeding`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Build loop deps ───────────────────────────────────────────────
|
|
695
|
+
const deps: LoopDeps = this.config.dryRun
|
|
696
|
+
? buildDryRunDeps({ maxIterations: this.config.maxIterations })
|
|
697
|
+
: await buildRealLoopDeps({ maxIterations: this.config.maxIterations });
|
|
698
|
+
|
|
699
|
+
// Inject transition publishing into onStep
|
|
700
|
+
const originalOnStep = deps.onStep;
|
|
701
|
+
deps.onStep = (step, state) => {
|
|
702
|
+
originalOnStep?.(step, state);
|
|
703
|
+
this._publishTransition(loopId, step, state);
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// ── Run the graph ─────────────────────────────────────────────────
|
|
707
|
+
log(
|
|
708
|
+
`Starting write-review loop (maxIterations=${this.config.maxIterations})`,
|
|
709
|
+
);
|
|
710
|
+
const loop: WriteReviewLoop = buildWriteReviewLoop(deps);
|
|
711
|
+
|
|
712
|
+
const finalState = await loop.invoke(
|
|
713
|
+
{ request: task.request },
|
|
714
|
+
{ configurable: { thread_id: loopId } },
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const verdict = finalState.review?.verdict ?? "blocked";
|
|
718
|
+
const iterations = finalState.iteration ?? 0;
|
|
719
|
+
|
|
720
|
+
log(`Loop finished: ${verdict} after ${iterations} iteration(s)`);
|
|
721
|
+
|
|
722
|
+
// ── Gate check after blocked verdict ────────────────────────────────
|
|
723
|
+
if (
|
|
724
|
+
verdict === "blocked" &&
|
|
725
|
+
effectivePolicy(task, this.config) === "on_blocked"
|
|
726
|
+
) {
|
|
727
|
+
log(`Blocked verdict + on_blocked policy — waiting for human ack`);
|
|
728
|
+
const approved = await waitForAck(
|
|
729
|
+
task,
|
|
730
|
+
this.config,
|
|
731
|
+
this.bus,
|
|
732
|
+
this.inboxDir,
|
|
733
|
+
);
|
|
734
|
+
if (!approved) {
|
|
735
|
+
log(`Human denied — finalizing as blocked`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── Publish loop.finished ──────────────────────────────────────────
|
|
740
|
+
publishLoopFinished(
|
|
741
|
+
this.bus,
|
|
742
|
+
loopId,
|
|
743
|
+
`${iterations} iteration(s) — ${verdict}`,
|
|
744
|
+
iterations,
|
|
745
|
+
iterations,
|
|
746
|
+
verdict as LoopVerdict,
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// ── Notify on blocked / max-iterations ─────────────────────────────
|
|
750
|
+
if (verdict === "blocked") {
|
|
751
|
+
this._notifyHumanReviewNeeded(
|
|
752
|
+
task,
|
|
753
|
+
`Loop blocked: ${finalState.review?.summary ?? "no details"}`,
|
|
754
|
+
);
|
|
755
|
+
} else if (verdict === "changes_requested") {
|
|
756
|
+
this._notifyHumanReviewNeeded(
|
|
757
|
+
task,
|
|
758
|
+
`${iterations} iteration(s) exhausted with changes still requested: ${finalState.review?.comments.map((c) => c.comment).join("; ") ?? "no details"}`,
|
|
759
|
+
);
|
|
760
|
+
} else {
|
|
761
|
+
// approved
|
|
762
|
+
this._notifyReadyForClient(task);
|
|
763
|
+
}
|
|
764
|
+
} finally {
|
|
765
|
+
this._releaseLease(task.taskId);
|
|
766
|
+
this.running.delete(task.taskId);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private _publishTransition(
|
|
771
|
+
loopId: string,
|
|
772
|
+
step: string,
|
|
773
|
+
state: LoopState,
|
|
774
|
+
): void {
|
|
775
|
+
switch (step) {
|
|
776
|
+
case "plan": {
|
|
777
|
+
// Publish loop.started on the plan step
|
|
778
|
+
publishLoopStarted(this.bus, {
|
|
779
|
+
loopId,
|
|
780
|
+
writeCount: 0,
|
|
781
|
+
reviewCount: 0,
|
|
782
|
+
prompt: state.request ?? "",
|
|
783
|
+
nextReviewAfter: 0,
|
|
784
|
+
createdAt: new Date().toISOString(),
|
|
785
|
+
});
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case "write":
|
|
789
|
+
publishCodeWritten(this.bus, loopId, state.iteration, [], undefined);
|
|
790
|
+
break;
|
|
791
|
+
case "review":
|
|
792
|
+
publishReviewCompleted(
|
|
793
|
+
this.bus,
|
|
794
|
+
loopId,
|
|
795
|
+
state.iteration,
|
|
796
|
+
state.review?.verdict ?? "blocked",
|
|
797
|
+
state.review?.summary ?? "",
|
|
798
|
+
this._reviewReportPath(loopId, state.iteration),
|
|
799
|
+
);
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private _reviewReportPath(loopId: string, iteration: number): string {
|
|
805
|
+
return `${ensureHerdrWorkspace().reviews}/${loopId}-review-${iteration}.md`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private _releaseLease(taskId: string): void {
|
|
809
|
+
try {
|
|
810
|
+
this.leaseManager.release(taskId, this.agentId);
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.error(`[daemon] Failed to release lease for ${taskId}: ${err}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ─── Notifications ────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
private _notifyHumanReviewNeeded(task: TriggeredTask, reason: string): void {
|
|
819
|
+
if (!this.notificationCenter) return;
|
|
820
|
+
console.log(`[daemon] Notifying HumanReviewNeeded: ${reason}`);
|
|
821
|
+
this.notificationCenter
|
|
822
|
+
.notify("HumanReviewNeeded", {
|
|
823
|
+
jobId: task.taskId,
|
|
824
|
+
requirement: task.request,
|
|
825
|
+
taskId: task.taskId,
|
|
826
|
+
error: reason,
|
|
827
|
+
})
|
|
828
|
+
.catch((err) => {
|
|
829
|
+
console.warn(`[daemon] Notification failed: ${err}`);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private _notifyReadyForClient(task: TriggeredTask): void {
|
|
834
|
+
if (!this.notificationCenter) return;
|
|
835
|
+
console.log(`[daemon] Notifying ReadyForClient`);
|
|
836
|
+
this.notificationCenter
|
|
837
|
+
.notify("ReadyForClient", {
|
|
838
|
+
jobId: task.taskId,
|
|
839
|
+
requirement: task.request,
|
|
840
|
+
taskId: task.taskId,
|
|
841
|
+
})
|
|
842
|
+
.catch((err) => {
|
|
843
|
+
console.warn(`[daemon] Notification failed: ${err}`);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private _buildChannels(cfg: NonNullable<DaemonConfig["notificationConfig"]>) {
|
|
848
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
849
|
+
const channels: any[] = [];
|
|
850
|
+
if (cfg.telegramBotToken && cfg.telegramChatId) {
|
|
851
|
+
channels.push({
|
|
852
|
+
id: "telegram",
|
|
853
|
+
type: "telegram",
|
|
854
|
+
enabled: true,
|
|
855
|
+
config: { botToken: cfg.telegramBotToken, chatId: cfg.telegramChatId },
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
if (cfg.ntfyTopic) {
|
|
859
|
+
channels.push({
|
|
860
|
+
id: "ntfy",
|
|
861
|
+
type: "ntfy",
|
|
862
|
+
enabled: true,
|
|
863
|
+
config: { server: "https://ntfy.sh", topic: cfg.ntfyTopic },
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (cfg.webhookUrl) {
|
|
867
|
+
channels.push({
|
|
868
|
+
id: "webhook",
|
|
869
|
+
type: "webhook",
|
|
870
|
+
enabled: true,
|
|
871
|
+
config: { url: cfg.webhookUrl },
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return channels;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ─── Default export ─────────────────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
export { LoopDaemon as default };
|