privateer-agent 0.3.5 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/bin/privateer-daemon.mjs +30 -0
  3. package/bin/privateer-subagent.mjs +68 -0
  4. package/bin/privateer-tui +35 -5
  5. package/extensions/privateer-brand.ts +213 -34
  6. package/extensions/privateer-context.ts +59 -0
  7. package/extensions/privateer-gate.ts +351 -6
  8. package/extensions/privateer-posture.ts +18 -2
  9. package/extensions/privateer-privacy.ts +50 -1
  10. package/package.json +4 -1
  11. package/src/auth/privateer.ts +151 -19
  12. package/src/channels/bridge.ts +293 -0
  13. package/src/channels/discord.ts +210 -0
  14. package/src/channels/run.ts +383 -0
  15. package/src/channels/slack.ts +176 -0
  16. package/src/channels/status.ts +54 -0
  17. package/src/channels/telegram.ts +139 -0
  18. package/src/channels/types.ts +36 -0
  19. package/src/channels/whatsapp.ts +178 -0
  20. package/src/cli/chat.ts +414 -28
  21. package/src/cli/daemonCli.ts +67 -0
  22. package/src/config/version.ts +16 -0
  23. package/src/context.ts +171 -0
  24. package/src/crypto/accountTrust.ts +113 -0
  25. package/src/crypto/accountVerify.ts +138 -0
  26. package/src/crypto/terminalKey.ts +95 -0
  27. package/src/crypto/terminalUnseal.ts +62 -0
  28. package/src/daemon/index.ts +522 -34
  29. package/src/daemon/service.ts +232 -0
  30. package/src/ext/permissionGate.ts +38 -0
  31. package/src/permissions/classify.ts +49 -5
  32. package/src/providers/account.ts +20 -12
  33. package/src/remote/channelsControl.ts +192 -0
  34. package/src/remote/controlAuth.ts +67 -0
  35. package/src/remote/extensionsControl.ts +140 -0
  36. package/src/remote/liveTaskSession.ts +218 -0
  37. package/src/remote/relayClient.ts +524 -0
  38. package/src/remote/remoteBridge.ts +172 -0
  39. package/src/remote/routinesControl.ts +216 -0
  40. package/src/remote/skillsControl.ts +205 -0
  41. package/src/remote/subagentChannel.ts +261 -0
  42. package/src/remote/subagentRelay.ts +126 -0
  43. package/src/remote/workflowsControl.ts +132 -0
  44. package/src/routines/store.ts +5 -1
  45. package/src/workflows/expr.ts +4 -0
  46. package/src/workflows/runner.ts +8 -0
  47. package/src/workflows/schema.ts +5 -0
  48. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,210 @@
1
+ // Discord channel adapter — templated off the others against the same
2
+ // ChannelAdapter interface. Talks the Discord Gateway (a WebSocket) directly for
3
+ // receive and the REST API for send, so — like Telegram/Slack — it needs NO public
4
+ // inbound endpoint. Zero new dependencies: `ws` is already a dep (relayClient.ts)
5
+ // and REST is fetch.
6
+ //
7
+ // Discord app setup (one-time):
8
+ // - Create a bot, copy its token → `botToken`.
9
+ // - Enable the MESSAGE CONTENT intent (privileged) in the dev portal, else
10
+ // `content` arrives empty.
11
+ // - Invite the bot to your server, or DM it. `allowFrom` lists Discord user ids.
12
+ //
13
+ // Everything above the adapter (allowlist, serialization, redaction, chunking) is
14
+ // MessagingBridge, reused verbatim.
15
+
16
+ import WebSocket from "ws";
17
+ import type { ChannelAdapter, InboundMessage } from "./types.ts";
18
+
19
+ const GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
20
+ const API = "https://discord.com/api/v10";
21
+ const RECONNECT_MS = 3000;
22
+ const SEND_TIMEOUT_MS = 15_000;
23
+
24
+ // Gateway intent bits we need: guild messages, DMs, and (privileged) message
25
+ // content. https://discord.com/developers/docs/topics/gateway#gateway-intents
26
+ const INTENT_GUILD_MESSAGES = 1 << 9;
27
+ const INTENT_DIRECT_MESSAGES = 1 << 12;
28
+ const INTENT_MESSAGE_CONTENT = 1 << 15;
29
+ const DEFAULT_INTENTS = INTENT_GUILD_MESSAGES | INTENT_DIRECT_MESSAGES | INTENT_MESSAGE_CONTENT;
30
+
31
+ interface DiscordMessage {
32
+ channel_id?: string;
33
+ content?: string;
34
+ author?: { id?: string; username?: string; bot?: boolean };
35
+ }
36
+
37
+ // Pure mapping: a MESSAGE_CREATE payload → normalized InboundMessage, or null.
38
+ // Drops messages from ANY bot (`author.bot`, which includes our own) so the agent
39
+ // never talks to itself. Extracted so it's unit-testable without the Gateway.
40
+ export function messageFromDiscord(d: DiscordMessage): InboundMessage | null {
41
+ if (!d || !d.author || d.author.bot) return null;
42
+ if (typeof d.content !== "string" || !d.content || !d.channel_id || !d.author.id) return null;
43
+ return { chatId: d.channel_id, userId: d.author.id, userName: d.author.username, text: d.content };
44
+ }
45
+
46
+ export interface DiscordOptions {
47
+ botToken: string;
48
+ intents?: number;
49
+ fetchImpl?: typeof fetch;
50
+ onLog?: (msg: string) => void;
51
+ }
52
+
53
+ export class DiscordAdapter implements ChannelAdapter {
54
+ readonly name = "discord";
55
+ private readonly token: string;
56
+ private readonly intents: number;
57
+ private readonly fetchImpl: typeof fetch;
58
+ private readonly onLog?: (msg: string) => void;
59
+ private ws?: WebSocket;
60
+ private running = false;
61
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
62
+ private heartbeat?: ReturnType<typeof setInterval>;
63
+ private lastSeq: number | null = null; // last dispatch sequence (for heartbeats)
64
+ private acked = true; // did the server ack our last heartbeat? (zombie detection)
65
+ private onMessage?: (m: InboundMessage) => void;
66
+
67
+ constructor(opts: DiscordOptions) {
68
+ this.token = opts.botToken;
69
+ this.intents = opts.intents ?? DEFAULT_INTENTS;
70
+ this.fetchImpl = opts.fetchImpl ?? fetch;
71
+ this.onLog = opts.onLog;
72
+ }
73
+
74
+ async start(onMessage: (m: InboundMessage) => void): Promise<void> {
75
+ if (this.running) return;
76
+ this.running = true;
77
+ this.onMessage = onMessage;
78
+ this.connect();
79
+ }
80
+
81
+ stop(): void {
82
+ this.running = false;
83
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
84
+ this.clearHeartbeat();
85
+ try {
86
+ this.ws?.close();
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ this.ws = undefined;
91
+ }
92
+
93
+ async sendText(chatId: string, text: string): Promise<void> {
94
+ if (!text) return;
95
+ const res = await this.fetchImpl(`${API}/channels/${chatId}/messages`, {
96
+ method: "POST",
97
+ headers: { authorization: `Bot ${this.token}`, "content-type": "application/json" },
98
+ body: JSON.stringify({ content: text }),
99
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
100
+ });
101
+ if (!res.ok) this.log(`create-message failed: HTTP ${res.status}`);
102
+ }
103
+
104
+ private log(msg: string): void {
105
+ this.onLog?.(`discord: ${msg}`);
106
+ }
107
+
108
+ private connect(): void {
109
+ if (!this.running) return;
110
+ const ws = new WebSocket(GATEWAY);
111
+ this.ws = ws;
112
+ ws.on("message", (raw) => this.handleFrame(ws, raw));
113
+ ws.on("close", () => {
114
+ if (this.ws === ws) this.ws = undefined;
115
+ this.clearHeartbeat();
116
+ if (this.running) this.scheduleReconnect();
117
+ });
118
+ ws.on("error", (err: Error) => this.log(`ws error: ${err?.message ?? err}`));
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ if (!this.running || this.reconnectTimer) return;
123
+ this.reconnectTimer = setTimeout(() => {
124
+ this.reconnectTimer = undefined;
125
+ this.connect();
126
+ }, RECONNECT_MS);
127
+ }
128
+
129
+ private handleFrame(ws: WebSocket, raw: WebSocket.RawData): void {
130
+ let f: { op?: number; t?: string; s?: number | null; d?: any };
131
+ try {
132
+ f = JSON.parse(raw.toString());
133
+ } catch {
134
+ return;
135
+ }
136
+ if (typeof f.s === "number") this.lastSeq = f.s;
137
+ switch (f.op) {
138
+ case 10: // Hello — begin heartbeating, then identify
139
+ this.startHeartbeat(ws, f.d?.heartbeat_interval ?? 41_250);
140
+ this.identify(ws);
141
+ break;
142
+ case 11: // Heartbeat ACK
143
+ this.acked = true;
144
+ break;
145
+ case 1: // server requested a heartbeat now
146
+ this.sendHeartbeat(ws);
147
+ break;
148
+ case 7: // Reconnect
149
+ case 9: // Invalid Session — drop and re-identify fresh (no resume, for simplicity)
150
+ try {
151
+ ws.close();
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ break;
156
+ case 0: // Dispatch
157
+ if (f.t === "MESSAGE_CREATE") {
158
+ const m = messageFromDiscord(f.d);
159
+ if (m) this.onMessage?.(m);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+
165
+ private identify(ws: WebSocket): void {
166
+ this.send(ws, {
167
+ op: 2,
168
+ d: {
169
+ token: this.token,
170
+ intents: this.intents,
171
+ properties: { os: "linux", browser: "privateer", device: "privateer" },
172
+ },
173
+ });
174
+ }
175
+
176
+ private startHeartbeat(ws: WebSocket, intervalMs: number): void {
177
+ this.clearHeartbeat();
178
+ this.acked = true;
179
+ this.heartbeat = setInterval(() => {
180
+ if (!this.acked) {
181
+ // No ACK since the last beat → zombie connection; drop it to reconnect.
182
+ try {
183
+ ws.close();
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ return;
188
+ }
189
+ this.sendHeartbeat(ws);
190
+ }, intervalMs);
191
+ }
192
+
193
+ private sendHeartbeat(ws: WebSocket): void {
194
+ this.acked = false;
195
+ this.send(ws, { op: 1, d: this.lastSeq });
196
+ }
197
+
198
+ private clearHeartbeat(): void {
199
+ if (this.heartbeat) clearInterval(this.heartbeat);
200
+ this.heartbeat = undefined;
201
+ }
202
+
203
+ private send(ws: WebSocket, payload: unknown): void {
204
+ try {
205
+ ws.send(JSON.stringify(payload));
206
+ } catch {
207
+ /* socket dying */
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,383 @@
1
+ // Headless entry for the messaging channels — `npm run channels` (or
2
+ // `node --env-file=.env --import tsx src/channels/run.ts`). Boots the Pi stack the
3
+ // same way the routines daemon does, then bridges any configured chat platform
4
+ // (Telegram, Slack) to per-conversation agent sessions.
5
+ //
6
+ // AUTHORIZATION MODEL (per channel):
7
+ // - admins — governed by the channel `posture`; the ONLY users whose yes/no
8
+ // resolves an approval prompt.
9
+ // - members — may chat, but every turn runs read-only (writes/bash denied) and
10
+ // they cannot answer approvals.
11
+ // (Legacy `allowFrom` is treated as `admins` for back-compat.)
12
+ //
13
+ // POSTURE (config + restart only — deliberately no in-chat toggle; a restart is the
14
+ // fail-safe reset). Applies to ADMIN turns; members are always read-only:
15
+ // - readonly — deny every write/edit/bash/fetch
16
+ // - approve — each risky action prompts an admin in-chat for yes/no (default)
17
+ // - auto — non-dangerous actions run unattended; dangerous shell + destructive
18
+ // actions still prompt
19
+ // `tools` is the hard tool CEILING an admin can reach (default: read-only).
20
+ //
21
+ // MAINTENANCE: sessions are in-memory, per conversation, and evicted after 30 min
22
+ // idle or at a 500-session cap. A restart resets all live state to config (roles,
23
+ // posture). Every prompt/approval is appended to ~/.privateer/channels-audit.log.
24
+ // Tokens live in config.json in plaintext — protect that file's permissions.
25
+ //
26
+ // Config lives in ~/.privateer/config.json (the same file the daemon reads):
27
+ // {
28
+ // "defaultModel": "openrouter/openai/gpt-4o-mini",
29
+ // "channels": {
30
+ // "model": "openrouter/openai/gpt-4o-mini", // optional shared override
31
+ // "tools": ["read","grep","find","ls"], // optional shared ceiling
32
+ // "posture": "approve", // optional shared default
33
+ // "cwd": "/path/to/project", // optional (else process.cwd())
34
+ // "telegram": { "botToken": "…", "admins": ["<tg-id>"], "members": ["<tg-id>"],
35
+ // "posture": "approve", "tools": ["read","grep","find","ls","edit","write","bash"] },
36
+ // "slack": { "appToken": "xapp-…", "botToken": "xoxb-…", "admins": ["<slack-id>"] },
37
+ // "discord": { "botToken": "…", "admins": ["<discord-id>"], "intents": 37376 },
38
+ // "whatsapp": { "phoneNumberId": "…", "accessToken": "…", "verifyToken": "…",
39
+ // "appSecret": "…?", "port": 8787, "admins": ["<phone-number>"] }
40
+ // }
41
+ // }
42
+ // Each platform block is optional — only the configured ones start. Approvals are
43
+ // text-reply based (universal across all five platforms); mapping them to native
44
+ // buttons (Slack blocks / Discord components / Telegram inline keyboards) is a
45
+ // per-adapter enhancement.
46
+
47
+ import "../boot.ts"; // env + attestation dispatcher, before any Pi import
48
+ import { AsyncLocalStorage } from "node:async_hooks";
49
+
50
+ // Read-only default toolset — same rationale as the routines daemon's SAFE_TOOLS:
51
+ // a turn nobody is watching can't mutate the filesystem or shell out. Now that the
52
+ // gate routes approvals into the chat (see below), a user can safely widen this per
53
+ // channel via `channels.tools` — e.g. add "edit","write","bash" and each risky call
54
+ // prompts in-chat for a yes/no.
55
+ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
56
+
57
+ // A channel's posture governs how an ADMIN's risky actions are handled (members are
58
+ // always capped to read-only — see effectivePosture). Config + restart only; there
59
+ // is deliberately no in-chat toggle.
60
+ // readonly — deny every write/edit/bash/fetch (reads still run)
61
+ // approve — each risky action prompts in-chat for a yes/no (default)
62
+ // auto — non-dangerous actions run unattended; dangerous shell + destructive
63
+ // actions still prompt
64
+ type Posture = "readonly" | "approve" | "auto";
65
+ const POSTURES: Posture[] = ["readonly", "approve", "auto"];
66
+
67
+ // Bound the live session map so a long-running daemon can't grow without limit or
68
+ // hold stale context forever.
69
+ const MAX_SESSIONS = 500;
70
+ const SESSION_IDLE_MS = 30 * 60 * 1000; // evict a conversation unused for 30 min
71
+ const SESSION_SWEEP_MS = 5 * 60 * 1000;
72
+
73
+ // Per-turn context. AsyncLocalStorage carries it across the async tool-call hooks so
74
+ // the SHARED gate knows which conversation to prompt and the EFFECTIVE posture for
75
+ // this turn (which already folds in the triggering user's role), even with several
76
+ // chats running concurrently.
77
+ interface ApprovalContext {
78
+ bridge: { requestApproval(chatId: string, req: any, signal?: AbortSignal): Promise<"allow" | "deny"> };
79
+ chatId: string;
80
+ posture: Posture;
81
+ }
82
+ const approvalCtx = new AsyncLocalStorage<ApprovalContext>();
83
+
84
+ function parseSpec(spec: string): { provider: string; modelId: string } {
85
+ const i = spec.indexOf(":");
86
+ const j = spec.indexOf("/");
87
+ const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j);
88
+ if (sep <= 0) return { provider: spec, modelId: "" };
89
+ return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) };
90
+ }
91
+
92
+ function log(msg: string): void {
93
+ process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
94
+ }
95
+
96
+ function normalizePosture(v: unknown): Posture | undefined {
97
+ return typeof v === "string" && (POSTURES as string[]).includes(v) ? (v as Posture) : undefined;
98
+ }
99
+
100
+ async function main() {
101
+ const { readFileSync, appendFileSync } = await import("node:fs");
102
+ const { join } = await import("node:path");
103
+ const {
104
+ createAgentSessionServices,
105
+ createAgentSessionFromServices,
106
+ SessionManager,
107
+ } = await import("@earendil-works/pi-coding-agent");
108
+ const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
109
+ const { makePermissionGate } = await import("../ext/permissionGate.ts");
110
+ type GateController = import("../ext/permissionGate.ts").GateController;
111
+ const { makePiPrivacyExtension } = await import("pi-privacy");
112
+ const { makeAccountProvider } = await import("../providers/account.ts");
113
+ const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
114
+ const { redactText, collectSecrets } = await import("../util/redact.ts");
115
+ const { MessagingBridge } = await import("./bridge.ts");
116
+ type TurnRunner = import("./bridge.ts").TurnRunner;
117
+ const { TelegramAdapter } = await import("./telegram.ts");
118
+ const { SlackAdapter } = await import("./slack.ts");
119
+ const { DiscordAdapter } = await import("./discord.ts");
120
+ const { WhatsAppAdapter } = await import("./whatsapp.ts");
121
+ const { writeChannelsStatus, HEARTBEAT_MS } = await import("./status.ts");
122
+ type ChannelAdapter = import("./types.ts").ChannelAdapter;
123
+
124
+ // ── config ──────────────────────────────────────────────────────────────────
125
+ let cfg: any = {};
126
+ try {
127
+ cfg = JSON.parse(readFileSync(configPath(), "utf8"));
128
+ } catch {
129
+ log(`no config at ${configPath()} — add a channels block (see run.ts header).`);
130
+ process.exit(1);
131
+ }
132
+ const ch = cfg.channels ?? {};
133
+ const defaultModel: string = ch.model ?? cfg.defaultModel ?? "openrouter/openai/gpt-4o-mini";
134
+ const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length ? ch.tools : SAFE_TOOLS;
135
+ const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
136
+ const cwd: string = ch.cwd ?? process.cwd();
137
+ const secrets = collectSecrets(cfg.providers);
138
+ const redact = (t: string) => redactText(t, secrets);
139
+
140
+ // Append-only security audit log — every prompt, approval request/decision, and
141
+ // refused non-admin approval, one JSON object per line.
142
+ const auditPath = join(globalDir(), "channels-audit.log");
143
+ const onAudit = (e: any) => {
144
+ try {
145
+ appendFileSync(auditPath, JSON.stringify(e) + "\n");
146
+ } catch {
147
+ /* best effort — never let auditing break a turn */
148
+ }
149
+ };
150
+
151
+ // ── shared Pi session services (one registry/auth; sessions created per chat) ─
152
+ //
153
+ // The gate reads the EFFECTIVE posture for the current turn from the ALS store
154
+ // (which already folded in the triggering user's role): a member always resolves
155
+ // to "readonly". Mode "default" makes every write/edit/bash/fetch classify as
156
+ // "ask"; "plan" (readonly) hard-denies them. getRemote() is always true so asks
157
+ // route to the in-chat approver rather than a (non-existent) terminal. localAsk
158
+ // stays deny as a fail-closed backstop.
159
+ const posture = () => approvalCtx.getStore()?.posture;
160
+ const gate: GateController = {
161
+ getMode: () => (posture() === "readonly" ? "plan" : "default"),
162
+ setMode: () => {},
163
+ allowlist: [],
164
+ allowedOutsideRoots: [],
165
+ cwd,
166
+ confineToCwd: true,
167
+ getRemote: () => true,
168
+ getNoQuarter: () => posture() === "auto",
169
+ async localAsk() {
170
+ return "deny";
171
+ },
172
+ async remoteAsk(req, signal) {
173
+ const store = approvalCtx.getStore();
174
+ if (!store) return "deny"; // no chat context → fail closed
175
+ if (store.posture === "readonly") return "deny"; // read-only: deny, don't prompt
176
+ return store.bridge.requestApproval(store.chatId, req, signal);
177
+ },
178
+ };
179
+ const services = await createAgentSessionServices({
180
+ cwd,
181
+ agentDir: agentDir(),
182
+ resourceLoaderOptions: {
183
+ extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
184
+ },
185
+ });
186
+
187
+ const modelCache = new Map<string, any>();
188
+ function resolveModel(spec: string): any {
189
+ let m = modelCache.get(spec);
190
+ if (m === undefined) {
191
+ const { provider, modelId } = parseSpec(spec);
192
+ m = (services.modelRegistry as any).find(provider, modelId) ?? null;
193
+ modelCache.set(spec, m);
194
+ }
195
+ return m;
196
+ }
197
+
198
+ // One persistent session per conversation, keyed "<platform>:<chatId>" so chat
199
+ // ids can't collide across platforms. A single subscription per session routes
200
+ // streamed text to whichever turn is running (safe: the bridge serializes turns
201
+ // per chat).
202
+ interface SessionEntry {
203
+ session: any;
204
+ holder: { onText: (t: string) => void; error?: string };
205
+ lastUsed: number;
206
+ }
207
+ const sessions = new Map<string, SessionEntry>();
208
+
209
+ async function sessionFor(key: string, model: any, tools: string[]): Promise<SessionEntry> {
210
+ let entry = sessions.get(key);
211
+ if (!entry) {
212
+ const { session } = await createAgentSessionFromServices({
213
+ services,
214
+ sessionManager: SessionManager.inMemory(cwd),
215
+ model,
216
+ tools,
217
+ } as any);
218
+ const adapter = createEngineEventAdapter();
219
+ const holder: SessionEntry["holder"] = { onText: () => {}, error: undefined };
220
+ session.subscribe((ev: any) => {
221
+ for (const ee of adapter.toEngineEvents(ev)) {
222
+ if (ee.type === "text") holder.onText(ee.text);
223
+ else if (ee.type === "error") holder.error = ee.error;
224
+ }
225
+ });
226
+ entry = { session, holder, lastUsed: Date.now() };
227
+ sessions.set(key, entry);
228
+ // Hard cap: evict the least-recently-used conversation if we're over budget.
229
+ if (sessions.size > MAX_SESSIONS) {
230
+ let oldestKey: string | undefined;
231
+ let oldest = Infinity;
232
+ for (const [k, e] of sessions) {
233
+ if (e.lastUsed < oldest) {
234
+ oldest = e.lastUsed;
235
+ oldestKey = k;
236
+ }
237
+ }
238
+ if (oldestKey && oldestKey !== key) sessions.delete(oldestKey);
239
+ }
240
+ }
241
+ entry.lastUsed = Date.now();
242
+ return entry;
243
+ }
244
+
245
+ // Idle sweep: drop conversations untouched for SESSION_IDLE_MS. A dropped chat's
246
+ // next message just starts a fresh session (memory reset), so this is safe — turns
247
+ // are serialized, so an in-flight turn keeps its session recently-used.
248
+ const sweep = setInterval(() => {
249
+ const cutoff = Date.now() - SESSION_IDLE_MS;
250
+ for (const [k, e] of sessions) if (e.lastUsed < cutoff) sessions.delete(k);
251
+ }, SESSION_SWEEP_MS);
252
+ sweep.unref?.();
253
+
254
+ // ── build a bridge per configured platform ───────────────────────────────────
255
+ const bridges: { stop(): void }[] = [];
256
+ // Platforms with a live bridge — written to the heartbeat file so the app's
257
+ // channels manager (running on the daemon's relay, a separate process) can show
258
+ // a live/offline badge without talking to this process.
259
+ const startedPlatforms: string[] = [];
260
+
261
+ async function startChannel(platform: string, adapter: ChannelAdapter, block: any) {
262
+ // Roles. Legacy `allowFrom` is treated as admins (its prior meaning: the sole
263
+ // fully-capable users). Members are chat-only + read-only and can't approve.
264
+ const admins = new Set<string>((block.admins ?? block.allowFrom ?? []).map(String));
265
+ const members = new Set<string>((block.members ?? []).map(String));
266
+ if (admins.size === 0 && members.size === 0) {
267
+ log(`${platform}: no admins/members configured — skipping (fail-closed).`);
268
+ return;
269
+ }
270
+
271
+ const modelSpec: string = block.model ?? defaultModel;
272
+ const model = resolveModel(modelSpec);
273
+ if (!model) {
274
+ log(`${platform}: model "${modelSpec}" not found — skipping. Check the spec / provider keys.`);
275
+ return;
276
+ }
277
+ // Per-channel tool ceiling + posture. The ceiling is what admins CAN reach; the
278
+ // gate caps members to read-only regardless.
279
+ const chTools: string[] = Array.isArray(block.tools) && block.tools.length ? block.tools : defaultTools;
280
+ const chPosture: Posture = normalizePosture(block.posture) ?? defaultPosture;
281
+
282
+ // The runner references its own bridge (for approval routing via ALS), so the
283
+ // bridge is declared first and assigned just below.
284
+ let bridge: InstanceType<typeof MessagingBridge>;
285
+ const runTurn: TurnRunner = async (chatId, text, onText, _signal, meta) => {
286
+ const { session, holder } = await sessionFor(`${platform}:${chatId}`, model, chTools);
287
+ holder.onText = onText;
288
+ holder.error = undefined;
289
+ // A member's turn is always read-only, whatever the channel posture.
290
+ const effectivePosture: Posture = meta.isAdmin ? chPosture : "readonly";
291
+ try {
292
+ await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () => session.prompt(text));
293
+ } catch (e) {
294
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
295
+ } finally {
296
+ holder.onText = () => {};
297
+ }
298
+ return holder.error ? { ok: false, error: holder.error } : { ok: true };
299
+ };
300
+
301
+ bridge = new MessagingBridge({
302
+ adapter,
303
+ runTurn,
304
+ isAllowed: (m) => admins.has(m.userId) || members.has(m.userId),
305
+ isAdmin: (m) => admins.has(m.userId),
306
+ redact,
307
+ onLog: log,
308
+ onAudit: (e) => onAudit({ ...e, platform }),
309
+ });
310
+ await bridge.start();
311
+ bridges.push(bridge);
312
+ startedPlatforms.push(platform);
313
+ log(
314
+ `${platform} up — model ${modelSpec}, ceiling [${chTools.join(", ")}], posture ${chPosture}, ` +
315
+ `${admins.size} admin(s)/${members.size} member(s), cwd ${cwd}.`,
316
+ );
317
+ }
318
+
319
+ if (ch.telegram?.botToken) {
320
+ await startChannel(
321
+ "telegram",
322
+ new TelegramAdapter({ botToken: ch.telegram.botToken, onLog: log }),
323
+ ch.telegram,
324
+ );
325
+ }
326
+ if (ch.slack?.appToken && ch.slack?.botToken) {
327
+ await startChannel(
328
+ "slack",
329
+ new SlackAdapter({ appToken: ch.slack.appToken, botToken: ch.slack.botToken, onLog: log }),
330
+ ch.slack,
331
+ );
332
+ }
333
+ if (ch.discord?.botToken) {
334
+ await startChannel(
335
+ "discord",
336
+ new DiscordAdapter({ botToken: ch.discord.botToken, intents: ch.discord.intents, onLog: log }),
337
+ ch.discord,
338
+ );
339
+ }
340
+ if (ch.whatsapp?.phoneNumberId && ch.whatsapp?.accessToken && ch.whatsapp?.verifyToken) {
341
+ await startChannel(
342
+ "whatsapp",
343
+ new WhatsAppAdapter({
344
+ phoneNumberId: ch.whatsapp.phoneNumberId,
345
+ accessToken: ch.whatsapp.accessToken,
346
+ verifyToken: ch.whatsapp.verifyToken,
347
+ appSecret: ch.whatsapp.appSecret,
348
+ port: ch.whatsapp.port,
349
+ path: ch.whatsapp.path,
350
+ onLog: log,
351
+ }),
352
+ ch.whatsapp,
353
+ );
354
+ }
355
+
356
+ if (bridges.length === 0) {
357
+ log("no channels started — configure a channels.<platform> block in config.json.");
358
+ process.exit(1);
359
+ }
360
+
361
+ // Heartbeat: announce the live platforms now and refresh on a cadence so the app's
362
+ // channels manager can tell running from merely-configured. A stale/absent file
363
+ // reads as offline (see channels/status.ts).
364
+ writeChannelsStatus(startedPlatforms);
365
+ const heartbeat = setInterval(() => writeChannelsStatus(startedPlatforms), HEARTBEAT_MS);
366
+ heartbeat.unref?.();
367
+
368
+ const shutdown = () => {
369
+ log("shutting down");
370
+ clearInterval(sweep);
371
+ clearInterval(heartbeat);
372
+ writeChannelsStatus([]); // clear presence immediately, don't wait for staleness
373
+ for (const b of bridges) b.stop();
374
+ process.exit(0);
375
+ };
376
+ process.on("SIGINT", shutdown);
377
+ process.on("SIGTERM", shutdown);
378
+ }
379
+
380
+ main().catch((err) => {
381
+ process.stderr.write(`${err?.stack ?? err}\n`);
382
+ process.exit(1);
383
+ });