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
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync }
16
16
  import { hostname, userInfo } from "node:os";
17
17
  import { globalDir, credentialsPath } from "../config/paths.ts";
18
18
  import { isAccountCapCode } from "../engine/errors.ts";
19
+ import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
20
+ import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
19
21
 
20
22
  // Default Privateer API host. NOTE: this is still the legacy "helix" Render
21
23
  // hostname the mobile/web client also points at (client/config/environment.ts);
@@ -97,14 +99,42 @@ let _child: ChildSession | null = null;
97
99
  let _spawnInFlight: Promise<ChildSession> | null = null;
98
100
  let _refreshInFlight: Promise<ChildSession> | null = null;
99
101
 
102
+ // The most recent ACCOUNT-provider session (spawnAccountCredentials /
103
+ // refreshAccountCredentials). Pi owns this credential's lifecycle — it drives the
104
+ // account inference channel in the TUI — so it's a distinct server-side session
105
+ // (device row) from _child. We record only its latest access token here so exit
106
+ // cleanup / an explicit sign-out (revokeAccountSession) can kill it. Rotations
107
+ // overwrite it; the previous token is already dead server-side, so tracking only
108
+ // the latest is right.
109
+ //
110
+ // LIFECYCLE HAZARD: Pi PERSISTS this session to auth.json (with a ~24h `expires`) and
111
+ // reuses it on the next launch, refreshing only when `Date.now() >= expires` — it does
112
+ // NOT refresh reactively on a 401. So revoking it at exit while leaving the persisted
113
+ // copy in place would let the next run send a token that still looks valid but is dead
114
+ // server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
115
+ // The fix is to revoke it AND drop the persisted credential together: the caller must
116
+ // remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
117
+ // right after revokeLocalSessions() so the next launch spawns a fresh session instead
118
+ // of reusing the revoked one. Doing both is safe; doing only one is not. See
119
+ // revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
120
+ let _account: { accessToken: string } | null = null;
121
+
100
122
  export function loadCredentials(): Credentials | null {
101
- if (_cache !== undefined) return _cache;
123
+ // Only a successfully-read credential is memoized. A NEGATIVE result (file absent
124
+ // or unreadable) is deliberately NOT cached: the credential can appear mid-session
125
+ // — the account /login writes credentials.json AFTER the extensions have already
126
+ // booted, and under jiti each extension gets its OWN module instance of this file,
127
+ // so a login's saveCredentials() never reaches another extension's `_cache`. If we
128
+ // memoized the pre-login "absent" as null, that instance would report "not signed
129
+ // in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
130
+ // disk on each miss lets a later call see what a sign-in just wrote.
131
+ if (_cache) return _cache;
102
132
  const path = credentialsPath();
103
- if (!existsSync(path)) return (_cache = null);
133
+ if (!existsSync(path)) return null;
104
134
  try {
105
135
  _cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
106
136
  } catch {
107
- _cache = null;
137
+ return null;
108
138
  }
109
139
  return _cache;
110
140
  }
@@ -125,8 +155,12 @@ export function clearCredentials(): void {
125
155
  } catch {
126
156
  /* nothing to remove */
127
157
  }
158
+ // Drop the pinned account signing key too — it belongs to the account that just
159
+ // signed out; a different account must re-pin its own at link.
160
+ clearAccountSignKey();
128
161
  _cache = null;
129
162
  _child = null;
163
+ _account = null;
130
164
  }
131
165
 
132
166
  export function hasCredentials(): boolean {
@@ -171,6 +205,52 @@ function notifySessionExpired(): void {
171
205
  }
172
206
  }
173
207
 
208
+ // A server-pushed `session_revoked` relay frame arrived: the account signed this
209
+ // terminal out (from the app's Linked Devices). Treat it exactly like a lazily-
210
+ // detected expiry — wipe the local machine login and announce it via
211
+ // onSessionExpired — but PROMPTLY, while the process is live, instead of waiting
212
+ // for the next authedFetch/launch to hit a 401. The relay owner additionally
213
+ // drops remote access. No-op if we're already signed out, so a duplicate frame
214
+ // (or a frame racing an in-flight /signout) doesn't fire a spurious notice.
215
+ export function handleServerRevoke(): void {
216
+ if (!hasCredentials()) return;
217
+ clearCredentials();
218
+ notifySessionExpired();
219
+ }
220
+
221
+ // ── Sign-in notification ─────────────────────────────────────────────────────
222
+ // Fired when a Privateer login completes on this terminal — the cue for the UI to
223
+ // re-render its header/badge to the signed-in state. It reaches every sign-in entry
224
+ // point:
225
+ // - our dedicated /signin command and a FRESH /login → "Use a subscription" OAuth
226
+ // login both run the device-code flow, which fires this from pollForToken once
227
+ // credentials are written; and
228
+ // - an ALREADY-LINKED machine selecting the subscription runs no device code (it
229
+ // just spawns an account session), so privateerOAuthProvider.login() fires this
230
+ // itself — otherwise that path had no hook back to the header and it kept showing
231
+ // the stale "not signed in" banner until the next launch.
232
+ // A listener here refreshes the UI regardless of which path the user took.
233
+
234
+ type SignedInListener = () => void;
235
+ const _signedInListeners = new Set<SignedInListener>();
236
+
237
+ export function onSignedIn(listener: SignedInListener): () => void {
238
+ _signedInListeners.add(listener);
239
+ return () => _signedInListeners.delete(listener);
240
+ }
241
+
242
+ // Emit the sign-in signal. Exported so the account OAuth provider can announce a
243
+ // completed subscription login on the already-linked path (see the note above).
244
+ export function notifySignedIn(): void {
245
+ for (const listener of _signedInListeners) {
246
+ try {
247
+ listener();
248
+ } catch {
249
+ /* a failing listener must not break the auth path */
250
+ }
251
+ }
252
+ }
253
+
174
254
  // ── Device authorization flow ────────────────────────────────────────────────
175
255
 
176
256
  export interface DeviceCode {
@@ -197,7 +277,17 @@ async function postJson(base: string, path: string, body: unknown, init: Request
197
277
  // Step 1: ask the server for a device + user code the human will approve in-app.
198
278
  export async function requestDeviceCode(deviceLabel = defaultDeviceLabel()): Promise<DeviceCode> {
199
279
  const base = serverBaseUrl();
200
- const res = await postJson(base, "/auth/device/code", { deviceLabel });
280
+ // This terminal's public key rides the grant so the app can PIN it on approval
281
+ // (TOFU) — the trust anchor for later sealing secrets (channel tokens) that only
282
+ // this machine can open. Best-effort: if keygen fails we just omit it and the app
283
+ // falls back to terminal-only secret entry rather than blocking login.
284
+ let terminalPub: string | undefined;
285
+ try {
286
+ terminalPub = terminalPublicKeyBase64();
287
+ } catch {
288
+ /* no key → no app-sealed secrets for this terminal; login still proceeds */
289
+ }
290
+ const res = await postJson(base, "/auth/device/code", { deviceLabel, terminalPub });
201
291
  if (!res.ok) {
202
292
  throw new Error(`Couldn't start login (${res.status}). Check your connection or PRIVATEER_SERVER_URL.`);
203
293
  }
@@ -226,9 +316,13 @@ export async function pollForToken(
226
316
  const res = await postJson(base, "/auth/device/token", { device_code: code.device_code });
227
317
 
228
318
  if (res.ok) {
229
- const data = (await res.json()) as Omit<Credentials, "serverBaseUrl">;
230
- const creds: Credentials = { ...data, serverBaseUrl: base };
319
+ const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"> & { accountSignPub?: string };
320
+ const creds: Credentials = { accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user, serverBaseUrl: base };
231
321
  saveCredentials(creds);
322
+ // Pin the account's signing public key (TOFU) so channel-config from the app can
323
+ // be verified as genuinely coming from the account, not a forging relay (F7/F8).
324
+ pinAccountSignKey(data.accountSignPub);
325
+ notifySignedIn();
232
326
  return creds;
233
327
  }
234
328
 
@@ -409,23 +503,17 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
409
503
  }
410
504
 
411
505
  /**
412
- * Best-effort revoke of THIS terminal's child session on exit, so the terminal
413
- * disappears from the app's Linked Devices list immediately instead of
414
- * lingering until its access-token rows expire (24h server-side).
415
- *
416
- * Deliberately NOT authedFetch: that would spawn/refresh a session just to kill
417
- * it. If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
418
- * Bounded by a short timeout — exit must never hang on a slow network — and all
419
- * failures are swallowed; the server's TTL remains the fallback.
506
+ * DELETE the server-side session identified by `accessToken` (RFC-style bearer
507
+ * possession proof). Deliberately a raw fetch, NOT authedFetch authedFetch would
508
+ * spawn/refresh a brand-new session just to kill this one. Bounded by a short
509
+ * timeout so exit never hangs on a slow network, and all failures are swallowed;
510
+ * the server's TTL is the fallback.
420
511
  */
421
- export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
422
- const child = _child;
423
- if (!child) return;
424
- _child = null; // never reuse a session we've asked the server to revoke
512
+ async function deleteSession(accessToken: string, timeoutMs: number): Promise<void> {
425
513
  try {
426
514
  await fetch(`${serverBaseUrl()}/auth/session/current`, {
427
515
  method: "DELETE",
428
- headers: { Authorization: `Bearer ${child.accessToken}` },
516
+ headers: { Authorization: `Bearer ${accessToken}` },
429
517
  signal: AbortSignal.timeout(timeoutMs),
430
518
  });
431
519
  } catch {
@@ -433,6 +521,48 @@ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
433
521
  }
434
522
  }
435
523
 
524
+ /**
525
+ * Best-effort revoke of THIS terminal's child session (from authedFetch/apiRequest).
526
+ * If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
527
+ */
528
+ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
529
+ const child = _child;
530
+ if (!child) return;
531
+ _child = null; // never reuse a session we've asked the server to revoke
532
+ await deleteSession(child.accessToken, timeoutMs);
533
+ }
534
+
535
+ /**
536
+ * Best-effort revoke of the account-provider session (the one Pi drives for account
537
+ * inference). Called both on EXPLICIT sign-out AND as part of exit cleanup (via
538
+ * revokeLocalSessions) — safe in the exit path ONLY because the caller also drops Pi's
539
+ * persisted copy (authStorage.remove("privateer")) so the next launch spawns fresh
540
+ * rather than reusing this now-dead token. See the _account note and revokeLocalSessions.
541
+ */
542
+ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
543
+ const account = _account;
544
+ if (!account) return;
545
+ _account = null;
546
+ await deleteSession(account.accessToken, timeoutMs);
547
+ }
548
+
549
+ /**
550
+ * Revoke ALL server-side sessions this terminal created — the in-memory child session
551
+ * (authedFetch/apiRequest) AND the account-provider inference session — so the terminal
552
+ * drops off the app's Linked Devices list the moment it exits (Ctrl+C, /quit, SIGTERM …)
553
+ * instead of lingering until its token TTL (~24h). Best-effort, time-bounded, and the
554
+ * two revokes run in parallel so a slow network can't double the exit delay.
555
+ *
556
+ * IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
557
+ * launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
558
+ * copy right after this resolves — `authStorage.remove("privateer")` — or the next run
559
+ * will reuse the token we just revoked and dead-end on a 401 (see the _account note).
560
+ * Callers: cli/chat.ts cleanup() and daemon/index.ts shutdown().
561
+ */
562
+ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
563
+ await Promise.all([revokeChildSession(timeoutMs), revokeAccountSession(timeoutMs)]);
564
+ }
565
+
436
566
  // ── Logout ───────────────────────────────────────────────────────────────────
437
567
 
438
568
  /**
@@ -493,6 +623,7 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
493
623
  throw new Error("Your Privateer session expired. Run /login to sign in again.");
494
624
  }
495
625
  const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
626
+ _account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
496
627
  return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
497
628
  }
498
629
 
@@ -502,6 +633,7 @@ export async function refreshAccountCredentials(refresh: string): Promise<Accoun
502
633
  const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
503
634
  if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
504
635
  const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
636
+ _account = { accessToken }; // the rotated session is the one an explicit sign-out revokes
505
637
  return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
506
638
  }
507
639
 
@@ -0,0 +1,293 @@
1
+ // The transport-agnostic core of the messaging channels — the analog of
2
+ // RemoteBridge for the relay. It owns the policy that must be identical on every
3
+ // platform:
4
+ // - allowlist (who may drive the agent; fail-closed + silent to strangers)
5
+ // - serialization (one turn per conversation at a time; extra messages queue)
6
+ // - redaction (chat platforms are external egress — scrub before send)
7
+ // - chunking (respect the platform's per-message length cap)
8
+ //
9
+ // The agent itself is injected as `runTurn`, so this file stays Pi-free and
10
+ // unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
11
+ // The Pi-backed runner lives in ./run.ts.
12
+
13
+ import type { ChannelAdapter, InboundMessage } from "./types.ts";
14
+
15
+ // A minimal view of the permission request the gate hands us (see
16
+ // src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
17
+ // depend on the gate module.
18
+ export interface ApprovalRequest {
19
+ kind: string;
20
+ title: string;
21
+ detail: string;
22
+ }
23
+
24
+ // Run one agent turn for a conversation. `onText` receives streamed text deltas as
25
+ // they arrive; the bridge buffers/coalesces them. Resolves when the turn is done.
26
+ // `signal` aborts a queued/in-flight turn (e.g. the user sent "/stop").
27
+ export type TurnRunner = (
28
+ chatId: string,
29
+ text: string,
30
+ onText: (delta: string) => void,
31
+ signal: AbortSignal,
32
+ // The triggering user + their role, so the runner (and the gate it drives) can
33
+ // cap a member to read-only regardless of the channel's posture.
34
+ meta: TurnMeta,
35
+ ) => Promise<{ ok: boolean; error?: string }>;
36
+
37
+ export interface TurnMeta {
38
+ userId: string;
39
+ isAdmin: boolean;
40
+ }
41
+
42
+ // A security-audit event. The bridge emits these at authorization-relevant moments;
43
+ // run.ts appends them to an on-disk log. `detail` is redacted before it's emitted.
44
+ export interface AuditEvent {
45
+ at: string;
46
+ event: "prompt" | "approval_request" | "approval_decision" | "interrupt" | "denied";
47
+ chatId: string;
48
+ userId?: string;
49
+ role?: "admin" | "member";
50
+ detail?: string;
51
+ }
52
+
53
+ export interface MessagingBridgeConfig {
54
+ adapter: ChannelAdapter;
55
+ runTurn: TurnRunner;
56
+ // Who may interact at all (admin OR member). False → ignored (fail-closed and
57
+ // SILENT: we don't confirm the bot exists to un-allowlisted senders).
58
+ isAllowed: (msg: InboundMessage) => boolean;
59
+ // Is this user an admin? Admins are governed by the channel posture and are the
60
+ // ONLY users whose yes/no resolves an approval. Members are read-only and can't
61
+ // approve.
62
+ isAdmin: (msg: InboundMessage) => boolean;
63
+ // Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
64
+ redact?: (text: string) => string;
65
+ onLog?: (msg: string) => void;
66
+ // Optional append-only security audit sink.
67
+ onAudit?: (event: AuditEvent) => void;
68
+ }
69
+
70
+ // Stay comfortably under Telegram's 4096-char hard cap (Slack ~40k, Discord 2000 —
71
+ // pick the tightest common bound for the shared path; a platform with a smaller cap
72
+ // can override in its adapter later).
73
+ const MAX_MSG = 1900;
74
+
75
+ // How long to wait for a yes/no approval reply before failing closed (deny).
76
+ const APPROVAL_TIMEOUT_MS = 120_000;
77
+
78
+ const YES = new Set(["yes", "y", "allow", "ok", "okay", "approve", "approved", "👍", "✅"]);
79
+ const NO = new Set(["no", "n", "deny", "denied", "stop", "cancel", "reject", "👎", "❌"]);
80
+
81
+ // Interpret an approval reply. Returns null for anything that isn't a clear
82
+ // yes/no, so the bridge can re-prompt instead of guessing (fail-safe: never treat
83
+ // ambiguous text as allow).
84
+ export function approvalDecision(text: string): "allow" | "deny" | null {
85
+ const t = text.trim().toLowerCase();
86
+ if (YES.has(t)) return "allow";
87
+ if (NO.has(t)) return "deny";
88
+ return null;
89
+ }
90
+
91
+ // The message a user sees when the agent wants to run a gated action.
92
+ export function approvalPrompt(req: ApprovalRequest): string {
93
+ const detail = req.detail.length > 600 ? req.detail.slice(0, 600) + "\n…(truncated)" : req.detail;
94
+ return `⚠️ Approval needed — ${req.title} (${req.kind})\n\n${detail}\n\nReply "yes" to allow or "no" to deny (times out in 2 min).`;
95
+ }
96
+
97
+ // Split text into <=max chunks, preferring newline boundaries so code/paragraphs
98
+ // aren't cut mid-line when possible.
99
+ export function chunkText(text: string, max = MAX_MSG): string[] {
100
+ const out: string[] = [];
101
+ let rest = text;
102
+ while (rest.length > max) {
103
+ let cut = rest.lastIndexOf("\n", max);
104
+ if (cut < max * 0.5) cut = max; // no usable newline in the back half → hard cut
105
+ out.push(rest.slice(0, cut));
106
+ rest = rest.slice(cut).replace(/^\n/, "");
107
+ }
108
+ if (rest) out.push(rest);
109
+ return out;
110
+ }
111
+
112
+ export class MessagingBridge {
113
+ // Per-chat promise tail: each new turn chains onto the previous so turns in the
114
+ // same conversation never interleave (they share one agent session downstream).
115
+ private readonly tails = new Map<string, Promise<void>>();
116
+ // Per-chat abort handle for the in-flight turn, so "/stop" can interrupt it.
117
+ private readonly aborts = new Map<string, AbortController>();
118
+ // Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
119
+ // per chat (turns are serialized and a turn's tool calls are sequential).
120
+ private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
121
+
122
+ constructor(private readonly cfg: MessagingBridgeConfig) {}
123
+
124
+ async start(): Promise<void> {
125
+ await this.cfg.adapter.start((m) => this.onMessage(m));
126
+ this.log(`channel "${this.cfg.adapter.name}" listening`);
127
+ }
128
+
129
+ stop(): void {
130
+ this.cfg.adapter.stop();
131
+ for (const a of this.aborts.values()) a.abort();
132
+ this.aborts.clear();
133
+ // Fail any pending approvals closed so no turn hangs on shutdown.
134
+ for (const resolve of this.approvals.values()) resolve("deny");
135
+ this.approvals.clear();
136
+ }
137
+
138
+ // Ask the user in `chatId` to approve a gated tool action, and await their yes/no
139
+ // reply. Wired to the permission gate's remote approver (see channels/run.ts): the
140
+ // gate suspends the tool until this resolves. Fail-closed — timeout, abort (/stop),
141
+ // or shutdown all resolve to "deny". Public because the gate calls it directly
142
+ // (via an AsyncLocalStorage handle to this bridge + the current chat id).
143
+ requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
144
+ // Only one outstanding approval per chat; deny any stale one first.
145
+ this.approvals.get(chatId)?.("deny");
146
+
147
+ const prompt = approvalPrompt(req);
148
+ const detail = `${req.title}: ${req.detail}`;
149
+ this.cfg.onAudit?.({
150
+ at: new Date().toISOString(),
151
+ event: "approval_request",
152
+ chatId,
153
+ role: "admin", // approvals only arise from admin turns (members are read-only)
154
+ detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
155
+ });
156
+ void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt);
157
+
158
+ return new Promise<"allow" | "deny">((resolve) => {
159
+ let timer: ReturnType<typeof setTimeout>;
160
+ const settle = (decision: "allow" | "deny") => {
161
+ if (this.approvals.get(chatId) !== settle) return; // already settled
162
+ this.approvals.delete(chatId);
163
+ clearTimeout(timer);
164
+ signal?.removeEventListener("abort", onAbort);
165
+ resolve(decision);
166
+ };
167
+ const onAbort = () => {
168
+ void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
169
+ settle("deny");
170
+ };
171
+ timer = setTimeout(() => {
172
+ void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
173
+ settle("deny");
174
+ }, APPROVAL_TIMEOUT_MS);
175
+ this.approvals.set(chatId, settle);
176
+ if (signal) {
177
+ if (signal.aborted) return onAbort();
178
+ signal.addEventListener("abort", onAbort, { once: true });
179
+ }
180
+ });
181
+ }
182
+
183
+ private log(msg: string): void {
184
+ this.cfg.onLog?.(msg);
185
+ }
186
+
187
+ private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
188
+ if (!this.cfg.onAudit) return;
189
+ const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
190
+ this.cfg.onAudit({
191
+ at: new Date().toISOString(),
192
+ event,
193
+ chatId: m.chatId,
194
+ userId: m.userId,
195
+ role: this.cfg.isAdmin(m) ? "admin" : "member",
196
+ detail: red,
197
+ });
198
+ }
199
+
200
+ private onMessage(m: InboundMessage): void {
201
+ const text = m.text?.trim();
202
+ if (!text) return;
203
+
204
+ if (!this.cfg.isAllowed(m)) {
205
+ // Fail closed and stay silent — replying would confirm the bot to strangers.
206
+ this.log(`ignored message from unauthorized user ${m.userId} in chat ${m.chatId}`);
207
+ return;
208
+ }
209
+
210
+ // A pending tool approval in this chat consumes the next message as its answer —
211
+ // BEFORE the per-chat queue, because the turn awaiting approval is itself holding
212
+ // that queue open (routing the reply through the queue would deadlock it).
213
+ const pending = this.approvals.get(m.chatId);
214
+ if (pending) {
215
+ // Only admins may answer an approval. A member's reply is refused (and audited)
216
+ // — never silently treated as a decision.
217
+ if (!this.cfg.isAdmin(m)) {
218
+ this.audit(m, "denied", "non-admin attempted to answer an approval");
219
+ void this.cfg.adapter.sendText(m.chatId, "Only an admin can approve the pending action.");
220
+ return;
221
+ }
222
+ if (text === "/stop") {
223
+ this.audit(m, "approval_decision", "deny (/stop)");
224
+ pending("deny"); // interrupt while awaiting approval → deny it
225
+ return;
226
+ }
227
+ const decision = approvalDecision(text);
228
+ if (decision === null) {
229
+ void this.cfg.adapter.sendText(m.chatId, 'Reply "yes" to allow or "no" to deny the pending action.');
230
+ return;
231
+ }
232
+ this.audit(m, "approval_decision", decision);
233
+ pending(decision);
234
+ return;
235
+ }
236
+
237
+ // "/stop" interrupts the running turn instead of queueing another.
238
+ if (text === "/stop") {
239
+ this.aborts.get(m.chatId)?.abort();
240
+ return;
241
+ }
242
+
243
+ // Serialize per conversation: chain onto this chat's tail.
244
+ const prev = this.tails.get(m.chatId) ?? Promise.resolve();
245
+ const next = prev
246
+ .then(() => this.handle(m))
247
+ .catch((e) => this.log(`turn error: ${e instanceof Error ? e.message : String(e)}`));
248
+ this.tails.set(m.chatId, next);
249
+ // Drop the tail once this was the last queued turn, so the map doesn't grow.
250
+ void next.finally(() => {
251
+ if (this.tails.get(m.chatId) === next) this.tails.delete(m.chatId);
252
+ });
253
+ }
254
+
255
+ private async handle(m: InboundMessage): Promise<void> {
256
+ const { chatId } = m;
257
+ const isAdmin = this.cfg.isAdmin(m);
258
+ this.audit(m, "prompt", m.text.trim().slice(0, 200));
259
+ const ac = new AbortController();
260
+ this.aborts.set(chatId, ac);
261
+ this.cfg.adapter.sendTyping?.(chatId);
262
+
263
+ // Buffer the whole turn's text, then send once (coalesced) — the simplest
264
+ // correct choice. Streaming partial edits back to the channel is a future
265
+ // enhancement; buffering avoids a race between deltas and async sends and
266
+ // keeps this unit-testable without timers.
267
+ let buf = "";
268
+ let result: { ok: boolean; error?: string };
269
+ try {
270
+ result = await this.cfg.runTurn(chatId, m.text.trim(), (d) => (buf += d), ac.signal, {
271
+ userId: m.userId,
272
+ isAdmin,
273
+ });
274
+ } catch (e) {
275
+ result = { ok: false, error: e instanceof Error ? e.message : String(e) };
276
+ } finally {
277
+ this.aborts.delete(chatId);
278
+ }
279
+
280
+ const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
281
+
282
+ // Deliver any text the turn produced (even on error — a partial answer is
283
+ // useful), then an error line if it failed.
284
+ if (body.trim()) {
285
+ for (const chunk of chunkText(body)) await this.cfg.adapter.sendText(chatId, chunk);
286
+ } else if (result.ok) {
287
+ await this.cfg.adapter.sendText(chatId, "✓ done (no text output).");
288
+ }
289
+ if (!result.ok) {
290
+ await this.cfg.adapter.sendText(chatId, `⚠️ ${result.error ?? "the agent hit an error"}`);
291
+ }
292
+ }
293
+ }