@prismshadow/penguin-server 0.2.10 → 0.2.13

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 (80) hide show
  1. package/README.md +1 -1
  2. package/dist/api/types.d.ts +1014 -12
  3. package/dist/api/types.js +7 -0
  4. package/dist/auth-token.js +6 -4
  5. package/dist/auth-token.js.map +1 -1
  6. package/dist/{chunk-Q65VPAQS.js → chunk-3I2XOPJB.js} +102 -55
  7. package/dist/chunk-3I2XOPJB.js.map +1 -0
  8. package/dist/{chunk-ADZJYDNC.js → chunk-A3QANMU5.js} +293 -3
  9. package/dist/chunk-A3QANMU5.js.map +1 -0
  10. package/dist/chunk-B5IP37ZQ.js +56 -0
  11. package/dist/chunk-L2H3K4DT.js +7 -0
  12. package/dist/chunk-L2H3K4DT.js.map +1 -0
  13. package/dist/chunk-LPPVGHYD.js +83 -0
  14. package/dist/chunk-LPPVGHYD.js.map +1 -0
  15. package/dist/{chunk-TTGGJDNA.js → chunk-S3BI6R7I.js} +2 -2
  16. package/dist/{chunk-HOS3W3UP.js → chunk-TOOTEEM4.js} +50 -8
  17. package/dist/chunk-TOOTEEM4.js.map +1 -0
  18. package/dist/hmr/manifest.d.ts +1 -77
  19. package/dist/hmr/manifest.js +6 -1
  20. package/dist/index.js +50757 -20174
  21. package/dist/index.js.map +1 -1
  22. package/dist/initial-password.js +1 -0
  23. package/dist/lock.js +1 -0
  24. package/dist/machine-status.js +2 -1
  25. package/dist/machine-status.js.map +1 -1
  26. package/dist/plugin/index.d.ts +2595 -0
  27. package/dist/plugin/index.js.map +1 -0
  28. package/dist/reset-admin-password.js +7 -5
  29. package/dist/reset-admin-password.js.map +1 -1
  30. package/dist/secret-file.js +2 -0
  31. package/dist/secret-file.js.map +1 -1
  32. package/dist/version-report.js +3 -2
  33. package/package.json +11 -9
  34. package/web-dist/assets/{addon-clipboard-1cARCaK9.js → addon-clipboard-DrZY90Ks.js} +1 -1
  35. package/web-dist/assets/{addon-fit-BWdzl8Er.js → addon-fit-CtEAdAax.js} +1 -1
  36. package/web-dist/assets/{addon-web-links-DjXuvn4Z.js → addon-web-links-DJL13cYY.js} +1 -1
  37. package/web-dist/assets/c-BG-tZi7o.js +1 -0
  38. package/web-dist/assets/cpp-CaeG3uDQ.js +1 -0
  39. package/web-dist/assets/css-Br67jR8j.js +1 -0
  40. package/web-dist/assets/diff-jW5kmiru.js +1 -0
  41. package/web-dist/assets/github-dark-BQV5ugrc.js +1 -0
  42. package/web-dist/assets/github-light-Rcb2q3L5.js +1 -0
  43. package/web-dist/assets/go-B7VTfpnm.js +1 -0
  44. package/web-dist/assets/highlighter-CUb4XKtQ.js +2 -0
  45. package/web-dist/assets/{highlighter-Cu43iYr8.js → highlighter-core-CrBdruEx.js} +2 -2
  46. package/web-dist/assets/highlighter.worker-CL6f9Fzw.js +152 -0
  47. package/web-dist/assets/html-BW_6VhB7.js +1 -0
  48. package/web-dist/assets/index-CmfumtXy.js +570 -0
  49. package/web-dist/assets/index-Ywc2uM5u.css +1 -0
  50. package/web-dist/assets/ini-Dix3pTmg.js +1 -0
  51. package/web-dist/assets/java-BTDmaRNO.js +1 -0
  52. package/web-dist/assets/javascript-WyGyM1wV.js +1 -0
  53. package/web-dist/assets/json-B8JryHcM.js +1 -0
  54. package/web-dist/assets/jsx-8cLv2Six.js +1 -0
  55. package/web-dist/assets/log-CcaF_xNv.js +1 -0
  56. package/web-dist/assets/markdown-DX-TNHnc.js +1 -0
  57. package/web-dist/assets/php-CNeTOyEV.js +1 -0
  58. package/web-dist/assets/python-B6Ihcp-E.js +1 -0
  59. package/web-dist/assets/ruby-CYiNE6ps.js +1 -0
  60. package/web-dist/assets/rust-CxuBE0J8.js +1 -0
  61. package/web-dist/assets/shellscript-DgJyDXW6.js +1 -0
  62. package/web-dist/assets/sql-Dq2MiB0T.js +1 -0
  63. package/web-dist/assets/toml-Ms459DBw.js +1 -0
  64. package/web-dist/assets/tsx-D6116Ycy.js +1 -0
  65. package/web-dist/assets/typescript-BqvgUolh.js +1 -0
  66. package/web-dist/assets/xml-BkAJuBCc.js +1 -0
  67. package/web-dist/assets/{xterm-DKcY6q-I.js → xterm-B2oS8Mg8.js} +1 -1
  68. package/web-dist/assets/yaml-Bopit2L3.js +1 -0
  69. package/web-dist/index.html +2 -2
  70. package/dist/chunk-4SXGU7RO.js +0 -50
  71. package/dist/chunk-4SXGU7RO.js.map +0 -1
  72. package/dist/chunk-ADZJYDNC.js.map +0 -1
  73. package/dist/chunk-HOS3W3UP.js.map +0 -1
  74. package/dist/chunk-Q65VPAQS.js.map +0 -1
  75. package/dist/extension/index.d.ts +0 -212
  76. package/web-dist/assets/index-Bm9n7ZV-.js +0 -457
  77. package/web-dist/assets/index-DR4PrRzS.css +0 -1
  78. /package/dist/{extension/index.js.map → chunk-B5IP37ZQ.js.map} +0 -0
  79. /package/dist/{chunk-TTGGJDNA.js.map → chunk-S3BI6R7I.js.map} +0 -0
  80. /package/dist/{extension → plugin}/index.js +0 -0
@@ -0,0 +1,2595 @@
1
+ import { SpawnConfiner, ThinkingLevelName, OmniMessage, ToolCallPayload, ProjectConfig, ModelRef, ListEndpointModelsOptions, ProxyEnvPolicy, ControlEnvContext, ApproveFn, CompactAvailability, SessionTitleResult, BackgroundCommandInfo, ToolDetachResult, BackgroundSubagentInfo, SubagentMessageOptions, SubagentMessageOutcome } from '@prismshadow/penguin-core';
2
+ import { SandboxProviderSource, SandboxSettings, SandboxDimension } from '@prismshadow/penguin-core/plugin';
3
+ import { Slot, Opaque } from '@prismshadow/penguin-core/kernel';
4
+ import { ApprovalMode, SessionSource, ModelRefDto, ChatDefaultsDto, CommandPolicyDto, ModelVisionDetectRequest, ModelVisionDetectResponse, ModelTestRequest, ModelTestResponse, ModelProtocolDetectRequest, ModelProtocolDetectResponse, EndpointModelListRequest, EndpointModelListResponse, ModelsResponse, ModelsUpdateRequest, MessagingChannel as MessagingChannel$1, SessionInfo, SessionCategory, SessionCategoryCounts, ServerEvent, SessionStatus, SessionBackgroundTasks, PendingSteeringInfo, PendingFollowUpInfo, AgentConfigDto, AgentConfigUpdateRequest, AgentKernelUpdateResponse, VaultResponse, VaultUpdateRequest, PluginUpdateRef, MessagingRuntimeStatus, ContributionsResponse, RendererRef } from '../api/types.js';
5
+ import { ThinkingLevelName as ThinkingLevelName$1 } from '@prismshadow/penguin-core/interfaces';
6
+ import { Hono } from 'hono';
7
+ import '@prismshadow/penguin-core/omnimessage';
8
+
9
+ /**
10
+ * The sandbox service: owns the registered backends, routes each spawn's policy to a
11
+ * backend that implements the dimensions it requires, and produces the SpawnConfiner
12
+ * closure the runtime transports into core's command-session seam.
13
+ *
14
+ * Routing is by CAPABILITY, not by registration order alone: a policy requiring only
15
+ * `fs-write` goes to the first backend that covers it (the DSH adaptor, which works on
16
+ * Linux/macOS/Windows alike), while a policy also requiring `network` or `mask-paths`
17
+ * goes to the first backend implementing those (penguin-bwrap). Registration order
18
+ * only breaks ties between backends that both cover the request. A request nothing
19
+ * covers fails closed — never a silent unconfined run, and never a silently dropped
20
+ * dimension.
21
+ */
22
+
23
+ declare class SandboxService {
24
+ private readonly mounted;
25
+ /** name → why it failed to load; surfaced in the fail-closed message. */
26
+ private readonly loadErrors;
27
+ /**
28
+ * Ships with confinement OFF (`danger-full-access`): the default flips to
29
+ * workspace-write together with the deployment-facing config surface, so a
30
+ * deployment gains the switch before the enforcement — otherwise every host without
31
+ * a usable backend would fail every command the moment this code arrives.
32
+ */
33
+ private settings;
34
+ private readonly ready;
35
+ /**
36
+ * @param registrations - the backends plugins registered (see the sandbox registry on
37
+ * PenguinInterface). Loading is async, but create() and the confiner are sync: a
38
+ * confining policy resolved before loading settles fails closed rather than
39
+ * waiting. The default settings never consult a backend, so that window only
40
+ * exists for deployments flipping the mode in the first milliseconds after boot.
41
+ */
42
+ constructor(registrations?: Iterable<[string, SandboxProviderSource]>);
43
+ /** Resolves when every registered backend has loaded (or failed to). */
44
+ whenReady(): Promise<void>;
45
+ /** Replaces the active settings (the config surface's write path). */
46
+ configure(settings: SandboxSettings): void;
47
+ /** The active settings as a plain copy (the config surface's read side). */
48
+ currentSettings(): SandboxSettings;
49
+ /**
50
+ * The active settings as parked state, or undefined for the pristine default. The
51
+ * omission is load-bearing compatibility: a default deployment keeps parking
52
+ * `{ motd }`, which any platform bundle's context schema accepts. Once confinement
53
+ * is configured the field enters the document, and from then on pushing a
54
+ * sandbox-ignorant bundle is BLOCKED by schema validation — the right failure:
55
+ * refusing the swap beats a swap that silently un-confines the deployment.
56
+ */
57
+ parkedSettings(): SandboxSettings | undefined;
58
+ /** The mounted backends and what each implements (diagnostics / the config surface). */
59
+ backends(): Array<{
60
+ name: string;
61
+ dimensions: readonly SandboxDimension[];
62
+ }>;
63
+ /**
64
+ * The closure handed to the runtime: rewrites each spawn's argv under the active
65
+ * settings. Pure and self-contained, so the previous platform's confiner keeps
66
+ * serving during a hot-swap freeze window.
67
+ */
68
+ confiner(): SpawnConfiner;
69
+ /** The first mounted backend implementing every required dimension, or a fail-closed throw. */
70
+ private pick;
71
+ private failedSuffix;
72
+ }
73
+ declare const Sandbox_base: (abstract new () => Pick<SandboxService, "configure" | "currentSettings" | "parkedSettings" | "backends" | "confiner" | "whenReady">) & (<C>(target: C, context: ClassDecoratorContext) => C);
74
+ /** Confinement: settings, the active confiner, and which backends are mounted. */
75
+ declare abstract class Sandbox extends Sandbox_base {
76
+ }
77
+ interface SandboxSlots {
78
+ /**
79
+ * A backend: its static half here (name, the dimensions it implements), its code half
80
+ * bound by the contributor — a provider, or a promise of one for backends that probe.
81
+ */
82
+ providers: Slot<{
83
+ name: string;
84
+ dimensions: SandboxDimension[];
85
+ }, SandboxProviderSource>;
86
+ }
87
+
88
+ /**
89
+ * Screen snapshot -> ANSI restore stream, plus plain-text capture.
90
+ *
91
+ * This is what makes a browser refresh (or a reconnect from a second device) show the
92
+ * terminal exactly as it was: the daemon keeps an authoritative headless xterm per pty, and
93
+ * on attach it *re-renders* that grid as an escape-sequence stream the client's real xterm
94
+ * replays into an identical screen. That beats the two obvious alternatives:
95
+ *
96
+ * - Replaying the raw pty byte log: unbounded, and full-screen programs (vim, top) replay
97
+ * as a flicker of stale frames.
98
+ * - Sending a JSON cell grid: a 200k-object payload per attach, parsed on the UI thread.
99
+ *
100
+ * Cursor alignment is the subtle part. The restore writes `scrollback + rows` lines, so the
101
+ * client's viewport ends up holding exactly the server's viewport, and only then is the
102
+ * cursor placed with a viewport-relative CUP. Trailing blank rows are therefore written on
103
+ * purpose — dropping them would shift every subsequent row and land the cursor in the wrong
104
+ * place.
105
+ */
106
+
107
+ interface CaptureLinesOptions {
108
+ /** Line index into scrollback+viewport; negative counts from the end (-1 = last line). */
109
+ start?: number;
110
+ end?: number;
111
+ }
112
+ interface CaptureLinesResult {
113
+ lines: string[];
114
+ totalLines: number;
115
+ }
116
+
117
+ interface TerminalExitInfo {
118
+ exitCode: number;
119
+ signal: number | null;
120
+ lastOutputLines: string[];
121
+ }
122
+ interface CreateTerminalSessionOptions {
123
+ id?: string;
124
+ /** Absolute working directory (already resolved and validated by the manager). */
125
+ cwd: string;
126
+ /** Owning user id — a shell is the most privileged thing this server hands out. */
127
+ ownerUserId: string;
128
+ /**
129
+ * Stable per-user display number (tmux-style), assigned by the manager at creation. It
130
+ * travels with the terminal for its whole life — UI numbering must never depend on list
131
+ * position, which changes under user-reordered tabs.
132
+ */
133
+ seq?: number;
134
+ name?: string;
135
+ shell?: string;
136
+ cols?: number;
137
+ rows?: number;
138
+ env?: Record<string, string>;
139
+ /**
140
+ * Where a pushed bundle's node-pty assets live (the hmr capability's assetsDir);
141
+ * absent falls back to the packaged require (see pty-module.ts).
142
+ */
143
+ assets?: () => string | null;
144
+ }
145
+ interface TerminalSessionInfo {
146
+ id: string;
147
+ seq: number;
148
+ name: string;
149
+ cwd: string;
150
+ ownerUserId: string;
151
+ title: string | null;
152
+ cols: number;
153
+ rows: number;
154
+ pid: number;
155
+ createdAt: string;
156
+ alive: boolean;
157
+ exit: TerminalExitInfo | null;
158
+ }
159
+ type TerminalOutputListener = (data: string) => void;
160
+ type TerminalExitListener = (info: TerminalExitInfo) => void;
161
+ type TerminalTitleListener = (title: string | null) => void;
162
+ declare class TerminalSession {
163
+ readonly id: string;
164
+ readonly seq: number;
165
+ readonly cwd: string;
166
+ readonly ownerUserId: string;
167
+ readonly createdAt: Date;
168
+ private name;
169
+ private readonly terminal;
170
+ private readonly ptyProcess;
171
+ private readonly inputMode;
172
+ private readonly outputListeners;
173
+ private readonly exitListeners;
174
+ private readonly titleListeners;
175
+ private title;
176
+ private pendingTitle;
177
+ private titleTimer;
178
+ private sizeState;
179
+ private exitInfo;
180
+ private disposed;
181
+ constructor(options: CreateTerminalSessionOptions);
182
+ /** DA1 / DSR / OSC colour queries — see OSC_COLOR_QUERY_RESPONSES. */
183
+ private registerCapabilityResponders;
184
+ get alive(): boolean;
185
+ get exit(): TerminalExitInfo | null;
186
+ info(): TerminalSessionInfo;
187
+ rename(name: string): void;
188
+ write(data: string): void;
189
+ /**
190
+ * Applies a size request under the claim/update ownership rule, resizing both the pty and
191
+ * the daemon's emulator so snapshots keep matching what the shell laid out.
192
+ */
193
+ resize(request: {
194
+ connectionId: string;
195
+ cols: number;
196
+ rows: number;
197
+ intent: "claim" | "update";
198
+ }): boolean;
199
+ releaseSize(connectionId: string): void;
200
+ get size(): {
201
+ cols: number;
202
+ rows: number;
203
+ };
204
+ /** The attach payload: the whole screen as an ANSI stream (see snapshot.ts). */
205
+ restoreStream(options?: {
206
+ scrollbackLines?: number;
207
+ }): string;
208
+ capture(options?: CaptureLinesOptions): CaptureLinesResult;
209
+ onOutput(listener: TerminalOutputListener): () => void;
210
+ onExit(listener: TerminalExitListener): () => void;
211
+ onTitleChange(listener: TerminalTitleListener): () => void;
212
+ kill(signal?: string): void;
213
+ dispose(): void;
214
+ }
215
+
216
+ interface CreateTerminalRequest {
217
+ cwd: string;
218
+ ownerUserId: string;
219
+ name?: string;
220
+ cols?: number;
221
+ rows?: number;
222
+ shell?: string;
223
+ }
224
+ /** A live pty: a host-like object (it owns a process), compared by name. */
225
+ type Terminal = Opaque<"TerminalSession", TerminalSession>;
226
+ declare const Terminals_base: (abstract new () => {
227
+ create(request: CreateTerminalRequest): Promise<Terminal>;
228
+ adopt(ids: readonly string[]): void;
229
+ quiesce(): void;
230
+ handleIds(): string[];
231
+ require(id: string, userId: string): Terminal;
232
+ get(id: string): Terminal | undefined;
233
+ list(userId: string): Terminal[];
234
+ listInfo(userId: string): TerminalSessionInfo[];
235
+ kill(id: string, userId: string): void;
236
+ disposeAll(): void;
237
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
238
+ /** Live ptys: the spawn primitive behind /api/terminals and the terminal WebSocket. */
239
+ declare abstract class Terminals extends Terminals_base {
240
+ }
241
+
242
+ /**
243
+ * The resolved, in-force limits for one request, in bytes. Read fresh per request from the
244
+ * settings repo rather than snapshotted at boot, so an admin's change applies to the very next
245
+ * upload without a restart.
246
+ */
247
+ interface AttachmentLimits {
248
+ /** Per-file cap. */
249
+ maxBytes: number;
250
+ /** Per-request total of decoded attachment bytes. */
251
+ totalBytes: number;
252
+ /** Per-request file count. */
253
+ maxCount: number;
254
+ }
255
+
256
+ /**
257
+ * A queued message's file attachment as held for recall: the scratchpad path it was written
258
+ * to, plus what is needed to rebuild the composer-shaped part — the submitted name and media
259
+ * type (the bytes stay on disk; queued entries never hold file payloads in memory).
260
+ */
261
+ interface RecallableFile {
262
+ fileName: string;
263
+ path: string;
264
+ mime: string;
265
+ }
266
+
267
+ declare const SessionIndex_base: (abstract new () => {
268
+ insert(row: SessionRow): void;
269
+ insertOrIgnore(row: SessionRow): void;
270
+ insertFork(sourceSessionId: string, row: SessionRow): SessionRow;
271
+ markOrgClient(sessionIds: readonly string[]): void;
272
+ markHasTrace(sessionId: string): void;
273
+ markDriven(sessionId: string, at: string): void;
274
+ touchLastActive(sessionId: string, at: string): void;
275
+ findById(sessionId: string): SessionRow | null;
276
+ listByAgent(projectId: string, agentId: string): SessionRow[];
277
+ listByProject(projectId: string): SessionRow[];
278
+ updateApprovalMode(sessionId: string, mode: ApprovalMode): void;
279
+ updateThinkingLevel(sessionId: string, level: ThinkingLevelName): void;
280
+ updateTitle(sessionId: string, title: string): void;
281
+ updateTitleIfNull(sessionId: string, title: string): void;
282
+ setArchived(sessionId: string, archivedAt: string | null): void;
283
+ replaceId(oldSessionId: string, newSessionId: string): void;
284
+ deleteByAgent(projectId: string, agentId: string): void;
285
+ deleteByProject(projectId: string): void;
286
+ deleteById(sessionId: string): void;
287
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
288
+ /** SessionIndex: the mechanism SessionsRepo implements. */
289
+ declare abstract class SessionIndex extends SessionIndex_base {
290
+ }
291
+ declare const SessionOrigins_base: (abstract new () => {
292
+ set(sessionId: string, source: SessionSource | null): void;
293
+ get(sessionId: string): SessionSource | null | undefined;
294
+ delete(sessionId: string): void;
295
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
296
+ /** SessionOrigins: the mechanism SessionSources implements. */
297
+ declare abstract class SessionOrigins extends SessionOrigins_base {
298
+ }
299
+
300
+ /**
301
+ * sessions table repo:
302
+ * Session index, approval mode, and auto-generated title; Session-level routes use this to look up project ownership.
303
+ */
304
+
305
+ interface SessionRow {
306
+ sessionId: string;
307
+ projectId: string;
308
+ agentId: string;
309
+ /** Provider group of the session's model (pairs with `modelId` to form the model reference). */
310
+ provider: string;
311
+ /** Upstream model_id of the session's model (sent as-is to AgentHub; never concatenated). */
312
+ modelId: string;
313
+ workspace: string;
314
+ approvalMode: ApprovalMode;
315
+ /**
316
+ * Thinking level pinned for THIS session (PATCH /api/sessions/:id); NULL = not pinned,
317
+ * so runs that carry no level of their own follow the Agent config as before. Sessions
318
+ * are never inserted with one — pinning is an explicit act inside a live conversation.
319
+ */
320
+ thinkingLevel?: ThinkingLevelName$1 | null;
321
+ /** Auto-generated session title; NULL = not yet generated (frontend shows "New Conversation"). */
322
+ title: string | null;
323
+ /** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
324
+ archivedAt?: string | null;
325
+ /**
326
+ * Creating client: "web" (created via the Web App), "cli" (created through the API by
327
+ * the CLI, or adopted from a Trace a legacy CLI-direct run left behind), "org" (opened by
328
+ * the organization runtime — a desk or a ticket session); NULL = legacy row from before
329
+ * the column existed, treated as web. Free text in SQLite, so a new value needs no
330
+ * migration. Provenance that outlives its organization: development mode's list is the
331
+ * one reader that filters on it, and it hides "org" rows whether or not the organization
332
+ * still exists. The schedule/subagent SOURCE is deliberately NOT a row field — core
333
+ * session_meta in the Trace stays the single source of truth for it
334
+ * (runtime/session-sources.ts); `client` is a separate, DB-only axis that meta never
335
+ * records.
336
+ */
337
+ client?: "web" | "cli" | "org" | null;
338
+ /** Cache: a Trace record exists (set at task start / adoption / subagent registration; backfilled by list hydration). */
339
+ hasTrace?: boolean;
340
+ /**
341
+ * Last activity this server drove for the session, ISO: stamped once when a run starts
342
+ * and once when it ends (see SessionManager.drive — a goal run is ONE drive over all its
343
+ * rounds, so it stamps twice, not per round). Set to createdAt at insert, and only ever
344
+ * moves forward (markDriven/touchLastActive clamp with MAX, so a backwards clock step
345
+ * cannot regress it). Rows this server never drives — CLI-adopted Sessions, subagent
346
+ * rows (their activity is driven through the parent's entry) — keep createdAt.
347
+ */
348
+ lastActiveAt: string;
349
+ createdAt: string;
350
+ }
351
+
352
+ /**
353
+ * Tool call approval: ApproveFn factory + pending
354
+ * approval registry.
355
+ *
356
+ * Approval mode semantics match the CLI (packages/cli/src/approval.ts):
357
+ * allow-all auto-approves; deny-all auto-denies; read-only allows read-only tools
358
+ * (permission==="r") and routes the rest to manual approval; always-ask routes
359
+ * everything to manual approval. Routing to manual approval registers a pending entry
360
+ * and pushes an `approval_request` via SSE, suspending until the frontend decides via
361
+ * `POST /approvals/:toolCallId`; no timeout — pending approvals are resolved to deny
362
+ * when the Task is interrupted (then proceeds through the abort flow).
363
+ *
364
+ * Every approval decision re-reads the current approval_mode (`getMode` reads the DB),
365
+ * so mode changes take effect immediately.
366
+ * Docs: /docs/tools § "Approval".
367
+ */
368
+
369
+ interface PendingApproval {
370
+ toolCall: OmniMessage<ToolCallPayload>;
371
+ origin?: string[];
372
+ }
373
+
374
+ /** A numbered channel event; `id` is `<epoch>-<seq>`, `data` is serialized single-line JSON. */
375
+ interface ChannelEvent {
376
+ id: string;
377
+ /** SSE event name; omitted (OmniMessage) means no `event:` line. */
378
+ event?: string;
379
+ data: string;
380
+ }
381
+ type ChannelListener = (evt: ChannelEvent) => void;
382
+ interface ChannelOptions {
383
+ maxBufferCount?: number;
384
+ maxBufferBytes?: number;
385
+ }
386
+ declare class Channel {
387
+ /** Channel epoch: generated at instance creation, prefixed onto event ids (necessarily changes after recycle/recreate or restart). */
388
+ readonly epoch: string;
389
+ private nextSeq;
390
+ private buffer;
391
+ private bufferBytes;
392
+ /** Max seq among evicted events (0 means never evicted): lower bound for hit checks. */
393
+ private lastEvictedSeq;
394
+ private readonly listeners;
395
+ private readonly maxCount;
396
+ private readonly maxBytes;
397
+ /** Timestamp of last activity (publish/subscription change), used for idle-reclaim checks. */
398
+ lastActivityMs: number;
399
+ constructor(opts?: ChannelOptions);
400
+ get subscriberCount(): number;
401
+ /**
402
+ * Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
403
+ * yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
404
+ * lookup key. Used as the live-tail cursor on GET /messages: captured in the same
405
+ * synchronous tick as the fragment snapshot, it tells the client which buffered events
406
+ * the snapshot already covers.
407
+ */
408
+ get lastEventId(): string;
409
+ /** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
410
+ publish(data: unknown, event?: string): ChannelEvent;
411
+ /** Unicast an event to a single subscriber: consumes a seq but doesn't buffer or broadcast (used for replay at subscribe time). */
412
+ sendTo(listener: ChannelListener, data: unknown, event?: string): ChannelEvent;
413
+ subscribe(listener: ChannelListener): () => void;
414
+ /**
415
+ * Compute replay from a Last-Event-ID (`<epoch>-<seq>`): a mismatched epoch (channel
416
+ * recycled/recreated, process restarted, or malformed id) always misses; a matching
417
+ * epoch hits the buffer (if no events after that seq have been evicted and the seq was
418
+ * indeed assigned by this channel) and returns the buffered events after it; otherwise
419
+ * miss (the caller should send `resync_required` first).
420
+ */
421
+ replayAfter(lastEventId: string): {
422
+ hit: boolean;
423
+ events: ChannelEvent[];
424
+ };
425
+ private makeEvent;
426
+ }
427
+ interface ChannelHubOptions {
428
+ idleMs?: number;
429
+ /**
430
+ * Active check: keys for which this returns true are excluded from idle reclaim (app
431
+ * assembly injects `manager.statusOf(key) !== "idle"`, so a running/compacting
432
+ * Session channel is never reclaimed no matter how long since its last publish; a
433
+ * user channel key looks like `user:<id>` and is always considered active).
434
+ */
435
+ isActive?: (key: string) => boolean;
436
+ }
437
+ /**
438
+ * Channel collection: lazily created by key (Session id or `user:<user_id>`);
439
+ * a channel whose Session is idle and has had no subscribers for over 30 minutes is
440
+ * reclaimed, releasing its buffer as well.
441
+ */
442
+ declare class ChannelHub {
443
+ private readonly channels;
444
+ private readonly timer;
445
+ private readonly idleMs;
446
+ private isActive;
447
+ constructor(opts?: ChannelHubOptions);
448
+ /**
449
+ * Replaces the activity probe. "Is this session busy" is a business question the hub
450
+ * (runtime mechanism) cannot answer itself; the App installs its answer at create over
451
+ * the claimed hub — ordinary capability use, not a registry entry. Overwrite-only, the
452
+ * cross-generation discipline: a successor installs its own probe and a dead
453
+ * generation's is simply left behind (its manager has drained, so it reports idle —
454
+ * never wrong, only replaced).
455
+ */
456
+ setActivityProbe(probe: (key: string) => boolean): void;
457
+ get(key: string): Channel;
458
+ peek(key: string): Channel | undefined;
459
+ /**
460
+ * Publishes to every live channel whose key starts with `prefix` — global
461
+ * notices (e.g. `user:` for "the web assets were hot-swapped, reload").
462
+ * Only reaches channels that exist (i.e. someone connected at least once);
463
+ * that is the right scope for a notice that only matters to live clients.
464
+ */
465
+ broadcast(prefix: string, data: unknown, event?: string): void;
466
+ /** Reclaim idle channels (skips active Sessions: no reclaim even without a publish while awaiting approval); `now` is injectable for tests. */
467
+ sweep(now?: number): void;
468
+ dispose(): void;
469
+ }
470
+
471
+ /** Attribution context for one record (top-level Session scope). */
472
+ interface UsageContext {
473
+ projectId: string;
474
+ agentId: string;
475
+ /** Top-level Session id (the current actual id after self-heal). */
476
+ sessionId: string;
477
+ /** Vendor grouping for the top-level Session's model (paired with modelId; the fallback attribution when the origin mapping has no hit). */
478
+ provider: string;
479
+ /** Upstream model_id of the top-level Session (paired with provider). */
480
+ modelId: string;
481
+ }
482
+
483
+ /**
484
+ * Usage statistics query.
485
+ *
486
+ * Cost is **computed in real time**: usage_records only stores Tokens (pricing may
487
+ * be added later), so at query time each Model's cost is converted using the
488
+ * current Project's configured pricing — the repo returns raw Token totals broken
489
+ * down by `(provider, model_id)` paired reference, and this service looks up each
490
+ * reference's price once and folds it into cost / hasUncosted (if a Model has no
491
+ * pricing, its consumption is excluded from cost and hasUncosted is flagged).
492
+ * Summary cards (today / last 7 days / cumulative), grouped aggregation (date /
493
+ * agent / model / session, with the session dimension supporting agentId drill-down
494
+ * filtering), and the zero-filled time series the cost center's charts draw.
495
+ * Server-side error statistics (error_records) ride along on the same response:
496
+ * the statistics center fetches everything in one request, and filters are
497
+ * naturally shared; unattributed errors (login failures, process crashes, and other
498
+ * errors with no Project context) are visible only to admins, see the ErrorsRepo
499
+ * file header.
500
+ */
501
+
502
+ /** The three pricing buckets (usd_per_mtok convention), returned by the pricing lookup callback. */
503
+ interface PricingRates {
504
+ cacheRead: number;
505
+ cacheWrite: number;
506
+ output: number;
507
+ }
508
+ /**
509
+ * What one Model costs, in the tiers it can be billed in.
510
+ *
511
+ * `offPeak` differs from `peak` only for a catalog row on a time-based schedule whose stored
512
+ * price is still the catalog's own. Both are returned together because a query spans time: a
513
+ * week that straddles the boundary contains Tokens of both kinds, and each half is priced at
514
+ * the rate it actually ran at rather than at whichever tier is in force when it is read.
515
+ */
516
+ interface TieredRates {
517
+ peak: PricingRates;
518
+ offPeak: PricingRates;
519
+ }
520
+
521
+ declare const Errors_base: (abstract new () => {
522
+ record(args: ErrorRecordArgs): void;
523
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
524
+ /** Errors: the mechanism ErrorRecorder implements. */
525
+ declare abstract class Errors extends Errors_base {
526
+ }
527
+
528
+ /** Capture-site source (maps one-to-one to error_records.source). */
529
+ type ErrorSource = "http" | "organization" | "session" | "llm" | "environment" | "compaction" | "usage" | "title" | "subagent" | "process" | "schedule" | "messaging";
530
+ /** Error classification: see file header — the criterion is "does a human need to step in". */
531
+ type ErrorKind = "expected" | "unexpected";
532
+ /** Attribution context (all optional: the login endpoint has no Project, and process-level fallback has no request at all). */
533
+ interface ErrorContext {
534
+ projectId?: string;
535
+ agentId?: string;
536
+ sessionId?: string;
537
+ }
538
+ interface ErrorRecordArgs {
539
+ source: ErrorSource;
540
+ /** The caught error (unknown: the value caught may not be an Error; failures from the message stream pass the reason text directly). */
541
+ err: unknown;
542
+ ctx?: ErrorContext;
543
+ /** Semantic code (required for non-HTTP sources, e.g. session_run_failed); defaults to HttpError.code. */
544
+ code?: string;
545
+ /** HTTP status code; leave empty for non-HTTP sources. */
546
+ status?: number;
547
+ /** Explicit classification (see file header); defaults to inferring from `HttpError` — HTTP sources rely on this, other sources should pass it explicitly. */
548
+ kind?: ErrorKind;
549
+ }
550
+ declare class ErrorRecorder implements Errors {
551
+ /** Dedup table: `source \0 code \0 projectId` → timestamp of the last **persist** (see file header). */
552
+ private readonly lastSeen;
553
+ private readonly errors;
554
+ private readonly clock;
555
+ /** Record an error (synchronous, fails silently; same-window duplicates are dropped outright, see file header). */
556
+ record(args: ErrorRecordArgs): void;
557
+ /** true if a same-kind error was already recorded within the window (drop it); otherwise register this persist timestamp and keep the dedup table bounded. */
558
+ private deduped;
559
+ /** Keep the dedup table bounded (see file header): clear expired entries first; if still over the limit (hundreds/thousands of distinct error codes erupting at once), clear it entirely. */
560
+ private evict;
561
+ }
562
+ /** Minimal dependency a capture site needs on the recorder (tests inject a fake; structurally matches SessionManager's UsageRecorderLike). */
563
+ type ErrorSink = Pick<ErrorRecorder, "record">;
564
+
565
+ /** Host-side parameters for one title-generation request. */
566
+ interface TitleRequest {
567
+ /** The user input this run started from: its first words become the immediate fallback title. */
568
+ fallbackText: string;
569
+ /** Material override (the main session passes its input text, a subagent the prompt that spawned it — assistant material stays empty either way, so the request never waits on model output); defaults to the material self-collected by the core Session. */
570
+ material?: {
571
+ userText: string;
572
+ assistantText: string;
573
+ };
574
+ /** The channel to push the `session_title` event to; defaults to `ctx.sessionId`. A
575
+ * subagent has no SSE channel of its own, so its title must reach the frontend via
576
+ * the **parent Session's** channel (the list updates in place by sessionId). */
577
+ notifyOn?: string;
578
+ }
579
+ /** session-manager's minimal dependency on the title generator (tests inject a fake implementation). */
580
+ interface TitleNotifier {
581
+ maybeGenerate(ctx: UsageContext, session: Pick<RuntimeSession, "generateTitle">, req: TitleRequest): void;
582
+ }
583
+
584
+ /**
585
+ * trace_files / trace_sessions repo: the Trace-tree derived cache (see schema.ts and
586
+ * services/trace-index.ts for the cache rules). Pure row access — reconciliation
587
+ * policy (mtime gates, head-reads) lives in the service.
588
+ */
589
+
590
+ /** One indexed Trace shard. */
591
+ interface TraceFileRow {
592
+ projectId: string;
593
+ agentId: string;
594
+ sessionId: string;
595
+ fileIndex: number;
596
+ date: string;
597
+ sizeBytes: number;
598
+ }
599
+ /** One Session's registration-time facts (earliest shard's head). */
600
+ interface TraceSessionRow {
601
+ sessionId: string;
602
+ projectId: string;
603
+ agentId: string;
604
+ source: SessionSource | null;
605
+ workspace: string;
606
+ title: string | null;
607
+ provider: string | null;
608
+ modelId: string | null;
609
+ firstTs: string | null;
610
+ metaRead: boolean;
611
+ }
612
+
613
+ declare const TraceIndexStore_base: (abstract new () => {
614
+ upsertFile(row: TraceFileRow): void;
615
+ updateFileSize(projectId: string, agentId: string, sessionId: string, fileIndex: number, sizeBytes: number): void;
616
+ getPageStats(projectId: string, agentId: string, sessionId: string, fileIndex: number): string | null;
617
+ setPageStats(projectId: string, agentId: string, sessionId: string, fileIndex: number, sizeBytes: number, pageStats: string): void;
618
+ deleteFile(projectId: string, agentId: string, sessionId: string, fileIndex: number): void;
619
+ listFilesByAgent(projectId: string, agentId: string): TraceFileRow[];
620
+ listFilesBySession(projectId: string, agentId: string, sessionId: string): TraceFileRow[];
621
+ findAgentBySession(projectId: string, sessionId: string): string | null;
622
+ upsertSession(row: TraceSessionRow): void;
623
+ getSession(sessionId: string): TraceSessionRow | null;
624
+ listSessionsByAgent(projectId: string, agentId: string): TraceSessionRow[];
625
+ deleteBySession(sessionId: string): void;
626
+ deleteByAgent(projectId: string, agentId: string): void;
627
+ deleteByProject(projectId: string): void;
628
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
629
+ /** TraceIndexStore: the mechanism TraceIndexRepo implements. */
630
+ declare abstract class TraceIndexStore extends TraceIndexStore_base {
631
+ }
632
+ declare const TraceIndex_base: (abstract new () => {
633
+ readonly counters: {
634
+ gateStats: number;
635
+ dirScans: number;
636
+ headReads: number;
637
+ };
638
+ reconcileAgent(projectId: string, agentId: string, opts?: {
639
+ force?: boolean;
640
+ }): Promise<void>;
641
+ reconcileProject(projectId: string, opts?: {
642
+ force?: boolean;
643
+ }): Promise<void>;
644
+ registerImportedFile(args: {
645
+ projectId: string;
646
+ agentId: string;
647
+ sessionId: string;
648
+ fileIndex: number;
649
+ date: string;
650
+ sizeBytes: number;
651
+ records: OmniMessage[];
652
+ }): void;
653
+ removeSession(projectId: string, agentId: string, sessionId: string): void;
654
+ removeAgent(projectId: string, agentId: string): void;
655
+ removeProject(projectId: string): void;
656
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
657
+ /** TraceIndex: the mechanism TraceIndexService implements. */
658
+ declare abstract class TraceIndex extends TraceIndex_base {
659
+ }
660
+
661
+ /**
662
+ * "desktop" and "setup" may set a password without the old one (theirs is random and was never
663
+ * shown); desktop-only routes open to "desktop" alone. "token" is the local API token's Bearer
664
+ * header — per request, never a stored row. Anything else reads as "password".
665
+ */
666
+ type SessionVia = "password" | "desktop" | "setup" | "token";
667
+
668
+ interface UserRow {
669
+ userId: string;
670
+ passwordHash: string;
671
+ isAdmin: boolean;
672
+ /** Still using the initial password (seeded / set by an admin); cleared to 0 once the user changes it. */
673
+ passwordIsInitial: boolean;
674
+ /** Nickname shown in place of the id; null = never set. */
675
+ displayName: string | null;
676
+ /** Avatar as a data URL; null = never set. */
677
+ avatar: string | null;
678
+ createdAt: string;
679
+ }
680
+
681
+ type RawTable = Record<string, unknown>;
682
+ /**
683
+ * What one utility completion produced: the model's text, or why there is none. A bare null
684
+ * would say "no answer" and nothing else — and every one of these failures (no model
685
+ * configured, a missing credential, a provider rejection, a timeout, an answer that was all
686
+ * thinking) is one the user can act on, so the reason travels to the caller, which records it.
687
+ */
688
+ type UtilityCompletion = {
689
+ ok: true;
690
+ text: string;
691
+ } | {
692
+ ok: false;
693
+ /** `no_model`: the Project names no default model, or names one it has no entry for — nothing was asked. `failed`: the model was asked and produced no text. */
694
+ cause: "no_model" | "failed";
695
+ error: string;
696
+ };
697
+
698
+ declare const ProjectConfigStore_base: (abstract new () => {
699
+ readRaw(projectId: string): Promise<RawTable>;
700
+ loadConfig(projectId: string): Promise<ProjectConfig>;
701
+ writeRaw(projectId: string, data: RawTable): Promise<void>;
702
+ writeInitialConfig(projectId: string, name: string): Promise<void>;
703
+ ensurePresetModels(projectId: string): Promise<void>;
704
+ getName(projectId: string): Promise<string | undefined>;
705
+ setName(projectId: string, name: string): Promise<void>;
706
+ getDefaultModelRef(projectId: string): Promise<ModelRef | undefined>;
707
+ setDefaultModelRef(projectId: string, ref: ModelRefDto): Promise<ModelRefDto>;
708
+ getChatDefaults(projectId: string): Promise<ChatDefaultsDto>;
709
+ setChatDefaults(projectId: string, req: ChatDefaultsDto): Promise<ChatDefaultsDto>;
710
+ getCommandPolicy(projectId: string): Promise<CommandPolicyDto>;
711
+ setCommandPolicy(projectId: string, req: {
712
+ enabled?: boolean;
713
+ rules: {
714
+ name: string;
715
+ pattern: string;
716
+ description?: string;
717
+ enabled?: boolean;
718
+ }[];
719
+ }): Promise<CommandPolicyDto>;
720
+ getPricing(projectId: string, provider: string, modelId: string): Promise<TieredRates | undefined>;
721
+ detectVision(projectId: string, req: ModelVisionDetectRequest): Promise<ModelVisionDetectResponse>;
722
+ testModel(projectId: string, req: ModelTestRequest): Promise<ModelTestResponse>;
723
+ detectProtocol(projectId: string, req: ModelProtocolDetectRequest): Promise<ModelProtocolDetectResponse>;
724
+ listEndpointModels(req: EndpointModelListRequest, listImpl?: (options: ListEndpointModelsOptions) => Promise<string[]>, timeoutMs?: number): Promise<EndpointModelListResponse>;
725
+ getModels(projectId: string): Promise<ModelsResponse>;
726
+ updateModels(projectId: string, req: ModelsUpdateRequest): Promise<ModelsResponse>;
727
+ setGroupApiKey(projectId: string, provider: string, apiKey: string): Promise<number>;
728
+ completeOnce(projectId: string, prompt: string): Promise<UtilityCompletion>;
729
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
730
+ /** ProjectConfigStore: the mechanism ProjectConfigService implements. */
731
+ declare abstract class ProjectConfigStore extends ProjectConfigStore_base {
732
+ }
733
+
734
+ interface SessionServiceDeps {
735
+ root: string;
736
+ sessions: SessionIndex;
737
+ manager: SessionManager;
738
+ projectConfig: ProjectConfigStore;
739
+ /** In-process origin registry derived from session_meta (the DB stores no source column). */
740
+ sources: SessionOrigins;
741
+ /** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
742
+ traceIndex: TraceIndex;
743
+ /** The index rows themselves (files and sessions), for the reads the service does directly. */
744
+ traceStore: TraceIndexStore;
745
+ /**
746
+ * Admin proxy-settings threading (same getter the session loader passes, see
747
+ * createCoreSessionLoader): the runtime created here is adopted by the manager and
748
+ * runs the Session's first Task, so it needs the command-subprocess proxy policy too.
749
+ */
750
+ proxyEnv?: () => ProxyEnvPolicy | null;
751
+ /**
752
+ * Harness-control env threading (same policy the session loader passes): the server's
753
+ * API URL/token plus the Session coordinates, injected into command subprocesses so
754
+ * agents can drive the harness back through the CLI/API.
755
+ */
756
+ controlEnv?: (ctx: ControlEnvContext) => Record<string, string>;
757
+ /**
758
+ * PATH threading (same getter the session loader passes): the shim directory holding
759
+ * this harness's own `penguin`, put in front of every command an Agent runs.
760
+ */
761
+ pathPrepend?: () => string[];
762
+ /**
763
+ * The channel of the Session's ENABLED messaging binding, or null when none is enabled
764
+ * (SessionInfo.messagingChannel, the sidebar row's per-channel indicator — saved-but-
765
+ * disabled configs stay off the row). A lookup lambda rather than the repo, so the
766
+ * service stays decoupled from the bindings table; absent (older assemblies/tests)
767
+ * means the field is never set.
768
+ */
769
+ messagingChannel?: (sessionId: string) => MessagingChannel$1 | null;
770
+ /**
771
+ * Company mode: the organization owning a Session (a desk session, or a session
772
+ * contributing to a ticket), for `SessionInfo.orgId` — development mode's list hides
773
+ * those rows, the company sidebar groups them. Two shapes because the two flows cost
774
+ * differently: the single-Session GET asks about one id, a list asks once for the whole
775
+ * Project and looks its rows up in the returned map, so a long list never costs a query
776
+ * per row. Lambdas rather than the repo, so the service stays decoupled from the
777
+ * company-mode caches; absent (older assemblies/tests) means the field is never set.
778
+ */
779
+ orgIdOfSession?: (sessionId: string) => string | undefined;
780
+ orgIdsOfProject?: (projectId: string) => ReadonlyMap<string, string>;
781
+ /** Spawn-confinement getter (the sandbox module's), forwarded into core beside proxyEnv. */
782
+ confineSpawn?: () => SpawnConfiner | null;
783
+ }
784
+ declare class SessionService {
785
+ private readonly deps;
786
+ constructor(deps: SessionServiceDeps);
787
+ /**
788
+ * DB row -> SessionInfo (run status and pending approval count come from session-manager).
789
+ * Async because `source` is derived from session_meta: a registry miss (Session predating
790
+ * this process) falls back to reading the Trace head once (see sourceOf). `traces` is the
791
+ * list flow's one-walk discovery result; without it a miss locates the shard itself.
792
+ *
793
+ * `orgIds` is the list flow's one-query organization map (see listSessions); without it the
794
+ * organization is a point lookup, which is what the single-Session paths want.
795
+ */
796
+ toInfo(row: SessionRow, hasTrace: boolean, orgIds?: ReadonlyMap<string, string>): Promise<SessionInfo>;
797
+ /**
798
+ * A Session's origin, with session_meta as the single source of truth: the in-process
799
+ * registry answers first (populated at creation / subagent registration / adoption /
800
+ * index registration); on a miss (a Session created before this process started) the
801
+ * trace index's registration-time facts answer — the reconciler head-read the earliest
802
+ * shard once when the file first appeared, so no file is touched here. A Session with
803
+ * no Trace yet stays unknown and is NOT cached negatively — its meta may appear with
804
+ * the first run.
805
+ */
806
+ private sourceOf;
807
+ /** Whether this Session already has a Trace record (a Task has been run): answered by the index (reconciled first). */
808
+ hasTrace(row: SessionRow): Promise<boolean>;
809
+ /**
810
+ * The list category of a row: archived wins (an explicit user action), then the
811
+ * origin's bucket, and no/unknown source is `active` — the same precedence the
812
+ * sidebar's partition applies to loaded rows, so server filtering and client
813
+ * rendering can never disagree.
814
+ */
815
+ private categoryOf;
816
+ /**
817
+ * List, sorted by createdAt descending. Every row is served **straight from the DB**,
818
+ * whichever client created it, with no Trace directory scanning — the answer to
819
+ * many-session sidebar reloads re-walking the filesystem on every request (#139).
820
+ * Sessions living only in the Trace directory were adopted into the index by the
821
+ * boot-time sweep (`adoptUnmanagedTraceSessions`), so listing never discovers. One
822
+ * lazy discovery walk still runs for a list call that contains rows this process has
823
+ * not classified yet (no in-process source entry): it supplies the Trace locations for
824
+ * the one-time head reads and backfills the `has_trace` cache; once every row is
825
+ * classified, list calls touch only the DB.
826
+ *
827
+ * Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect
828
+ * "has more"); slicing happens before toInfo, so per-request source derivation (lazy
829
+ * Trace-head reads) stays bounded by the page size.
830
+ *
831
+ * `category` filters to one sidebar bucket **before** paging, so offset/limit page
832
+ * within the category. Filtering needs each walked row's category (a possible
833
+ * Trace-head read per row, cached in the sources registry); without `withCounts`
834
+ * the walk stops as soon as the requested page is complete. `withCounts` classifies
835
+ * every row and returns per-category totals over the whole list — plus the same
836
+ * totals broken down by Workspace path, and each path's newest Session's `createdAt` —
837
+ * so the sidebar can label the collapsed folders, list every Workspace that holds
838
+ * Sessions (not only the ones its loaded pages happen to touch) and place the groups
839
+ * by recency, all without loading them.
840
+ *
841
+ * `workspaceGroup` filters the same way, to one Workspace group (see workspace-group.ts),
842
+ * so a sidebar grouped by Workspace pages each group down its OWN stream instead of
843
+ * sharing one per-Agent cursor — without it, one group's "load more" consumes the page
844
+ * its siblings were about to read, and their rows move on screen untouched. The two
845
+ * filters compose; the returned counts stay whole-Agent either way.
846
+ */
847
+ listSessions(projectId: string, agentId: string, opts?: {
848
+ paging?: {
849
+ offset: number;
850
+ limit: number;
851
+ };
852
+ category?: SessionCategory;
853
+ workspaceGroup?: string;
854
+ withCounts?: boolean;
855
+ }): Promise<{
856
+ sessions: SessionInfo[];
857
+ counts?: SessionCategoryCounts;
858
+ workspaceCounts?: Record<string, SessionCategoryCounts>;
859
+ workspaceLatest?: Record<string, string>;
860
+ }>;
861
+ /**
862
+ * Session stats (Agents list card): total count = size of the union of DB index
863
+ * ∪ Trace directory discovery; activity = number of active Sessions per day over
864
+ * the last `days` days (deduplicated count of Sessions created that day or with a
865
+ * Trace record that day; index 0 = earliest, last index = today). Counts only —
866
+ * does not backfill index rows.
867
+ */
868
+ sessionStats(projectId: string, agentId: string, days: number): Promise<{
869
+ sessionCount: number;
870
+ activity: number[];
871
+ }>;
872
+ /**
873
+ * Create a Session: the model reference is a complete `(provider, modelId)` pair.
874
+ * Half a reference is a client error, never something to resolve — the missing half
875
+ * is never guessed, since a guessed provider would send an entry's credential to a
876
+ * vendor nobody named. Omitting both falls back to the Project's default reference
877
+ * (400 prompting to configure a model first if there is none). `workspace` is already
878
+ * validated by the route guard. The new Session is added to the active table
879
+ * (idle).
880
+ */
881
+ createSession(args: {
882
+ projectId: string;
883
+ agentId: string;
884
+ /** Upstream id of the session's model; always paired with provider. Omit both for the Project's default reference. */
885
+ modelId?: string;
886
+ /** The provider group for `modelId`; always paired with modelId, never inferred. */
887
+ provider?: string;
888
+ workspace?: string;
889
+ approvalMode?: ApprovalMode;
890
+ /**
891
+ * Session source marker: `schedule` when triggered by a scheduled task, `benchmark` when
892
+ * created by a Benchmark evaluation or optimization (the only value a client may send);
893
+ * defaults to user-created.
894
+ */
895
+ source?: "schedule" | "benchmark";
896
+ /**
897
+ * Creating-client hint stored on the index row (`POST .../sessions` body `client`):
898
+ * "cli" from the CLI, defaulting to "web". "org" is not accepted over HTTP — the
899
+ * organization runtime calls this method directly and is the only caller that passes
900
+ * it, so no request can claim an organization's provenance for itself.
901
+ */
902
+ client?: "web" | "cli" | "org";
903
+ }): Promise<SessionInfo>;
904
+ /**
905
+ * Absolute path of a Session's **latest** Trace file (the current context shard);
906
+ * undefined when no Trace exists. Costs a directory walk, so only the single-session
907
+ * GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch
908
+ * hands it to the new session's `[model_switch_from]` block so the model can read the
909
+ * source history itself when it needs it.
910
+ */
911
+ latestTracePath(row: SessionRow): Promise<string | undefined>;
912
+ /**
913
+ * One walk over the Trace directory: session_id → its **earliest** shard (the shard
914
+ * whose head carries the original session_meta). Discovery (which Sessions have
915
+ * records) and the meta-read location come out of a single pass, so classifying every
916
+ * row (`counts=1`) costs one directory walk total instead of one per Session.
917
+ */
918
+ private discoverTraces;
919
+ /**
920
+ * Startup adoption sweep: walks the whole trace tree once per boot and adopts every
921
+ * unmanaged Session (a Trace with no index row — legacy CLI-direct runs) as a
922
+ * `client: "cli"` row, so lists stay pure-SQLite afterwards. Enumerates
923
+ * `<root>/<project>/agents/<agent>/` directories directly — legacy Traces can live
924
+ * under Projects the DB has never seen — and reuses the existing discovery/adoption
925
+ * path per Agent (mtime-gated TraceIndexService reconcile, then registration-time
926
+ * facts; adoption reads no file itself). Idempotent (insertOrIgnore), so re-running —
927
+ * e.g. after a hot swap re-assembles the business surface — only costs the gated
928
+ * reconcile. Returns the number of rows adopted.
929
+ */
930
+ adoptUnmanagedTraceSessions(): Promise<number>;
931
+ /**
932
+ * Adopts a Session that exists only in the Trace directory, from the index's
933
+ * registration-time facts (the reconciler head-read the earliest shard's session_meta
934
+ * once when the file first appeared — adoption itself reads no file).
935
+ */
936
+ private adoptTraceSession;
937
+ }
938
+
939
+ /** Minimal interface for a runtime Session (satisfied by core Session; tests may inject a fake implementation). */
940
+ interface RuntimeSession {
941
+ readonly sessionId: string;
942
+ run(newMessages: OmniMessage[], opts: {
943
+ approve: ApproveFn;
944
+ signal: AbortSignal;
945
+ }): AsyncGenerator<OmniMessage>;
946
+ compact(opts: {
947
+ signal: AbortSignal;
948
+ }): AsyncGenerator<OmniMessage>;
949
+ /**
950
+ * The Session's thinking level (core `Session.thinkingLevel`, plain state): soft-limited —
951
+ * an assignment applies from the Session's very next LLM request. Optional: test fakes may
952
+ * omit it.
953
+ */
954
+ thinkingLevel?: ThinkingLevelName;
955
+ /** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
956
+ compactability(): CompactAvailability;
957
+ /** Queues a mid-run steering input (core `Session.steer`); false when no Task is running. */
958
+ steer(input: OmniMessage[]): boolean;
959
+ /**
960
+ * Withdraws a steering input queued via `steer` before core delivers it (core
961
+ * `Session.unsteer`, matched by input-list identity); false when it is no longer queued —
962
+ * already delivered, or the run exited. Optional: test fakes may omit it, in which case
963
+ * every recall reports "already delivered".
964
+ */
965
+ unsteer?(input: OmniMessage[]): boolean;
966
+ /** Skips the in-progress reconnect backoff, firing the next retry immediately (core `Session.skipReconnectWait`); false when no wait is in progress. */
967
+ skipReconnectWait(): boolean;
968
+ /**
969
+ * Runs the named package's `user_prompt` hook (core `Session.runUserPromptHook` — hooks
970
+ * run in core and nowhere else); null = the package is not installed or names no such
971
+ * command. Optional: test fakes may omit it, reading as not installed.
972
+ */
973
+ runUserPromptHook?(name: string, prompt: string, extras?: Record<string, string | number | boolean>): Promise<{
974
+ context?: string;
975
+ } | null>;
976
+ /** A tool's permission level from the running context's toolset (core `Session.toolPermission`; strict-tier — rebuilt at rotation). */
977
+ toolPermission(name: string): "r" | "rw" | undefined;
978
+ /**
979
+ * Out-of-band one-shot request for title generation (core `Session.generateTitle`,
980
+ * writes no history/Trace). Material defaults to what the Session collects itself
981
+ * (the first Task's text gathered during run); `material` overrides this for
982
+ * subagents.
983
+ */
984
+ generateTitle(args?: {
985
+ material?: {
986
+ userText: string;
987
+ assistantText: string;
988
+ };
989
+ signal?: AbortSignal;
990
+ }): Promise<SessionTitleResult>;
991
+ /**
992
+ * Subscribes the idle-arrival signal for background-task completion notices (core
993
+ * `Session.onBackgroundNotice`): fired when a `run_in_background` launch settles while no
994
+ * Task is running — the manager then takes the queue and submits it as an ordinary task.
995
+ * Optional: test fakes may omit it.
996
+ */
997
+ onBackgroundNotice?(listener: () => void): void;
998
+ /** Takes the queued background completion notices as task input (core `Session.takeBackgroundNotices`). Optional, like onBackgroundNotice. */
999
+ takeBackgroundNotices?(): OmniMessage[];
1000
+ /** Whether completion notices are still queued (core `Session.hasPendingBackgroundNotices`); pins the entry against idle eviction. Optional, like onBackgroundNotice. */
1001
+ hasPendingBackgroundNotices?(): boolean;
1002
+ /** Refreshes the listen-port probes behind the process list's `serviceUrl` (core `Session.probeBackgroundCommandServices`). Optional: test fakes may omit it. */
1003
+ probeBackgroundCommandServices?(): Promise<void>;
1004
+ /** Subscribes live-forwarded background-subagent messages (core `Session.onBackgroundMessage`); the manager publishes them to the session channel. Optional: test fakes may omit it. */
1005
+ onBackgroundMessage?(listener: (msg: OmniMessage) => void): void;
1006
+ /** Background command processes owned by the Session's environment (core `Session.listBackgroundCommands`). Optional: test fakes may omit it. */
1007
+ listBackgroundCommands?(): BackgroundCommandInfo[];
1008
+ /** Kills one background command process (core `Session.killBackgroundCommand`); false when the id is unknown. Optional, like listBackgroundCommands. */
1009
+ killBackgroundCommand?(processId: string): boolean;
1010
+ /** Hands one EXECUTING tool call back as a background task (core `Session.detachToolCall`). Optional: test fakes may omit it, reading as "not_running". */
1011
+ detachToolCall?(toolCallId: string): ToolDetachResult;
1012
+ /** Whether a background subagent is mid-round (core `Session.hasRunningBackgroundSubagents`); pins the entry against idle eviction. Optional, like listBackgroundCommands. */
1013
+ hasRunningBackgroundSubagents?(): boolean;
1014
+ /** All live subagent child sessions of the Session's environment (core `Session.listBackgroundSubagents`). Optional, like listBackgroundCommands. */
1015
+ listBackgroundSubagents?(): BackgroundSubagentInfo[];
1016
+ /** Host message to one child session — steering mid-run, a follow-up run when idle, a revival when released (core `Session.sendToBackgroundSubagent`). Optional. */
1017
+ sendToBackgroundSubagent?(childSessionId: string, messages: OmniMessage[], opts?: SubagentMessageOptions): Promise<SubagentMessageOutcome>;
1018
+ /** Subscribes the host's session-lifetime fallback approval sink for child sessions (core `Session.setSubagentApprovalFallback`). Optional. */
1019
+ setSubagentApprovalFallback?(approve: ApproveFn): void;
1020
+ /** Aborts one child session's current run, keeping the session (core `Session.abortBackgroundSubagentRun`); false when unknown or idle. Optional. */
1021
+ abortBackgroundSubagentRun?(childSessionId: string): boolean;
1022
+ /** Pins one live child session's thinking level (core `Session.setBackgroundSubagentThinkingLevel`); false when the child is not live. Optional. */
1023
+ setBackgroundSubagentThinkingLevel?(childSessionId: string, level: ThinkingLevelName): boolean;
1024
+ /** Subscribes subagent run-state changes (core `Session.onSubagentState`): the manager republishes `task_state` with the fresh live listing. Optional. */
1025
+ onSubagentState?(listener: () => void): void;
1026
+ /** Subscribes background-task state changes (core `Session.onBackgroundState`): the manager re-counts the live registries and publishes `session_background` when the counts moved. Optional. */
1027
+ onBackgroundState?(listener: () => void): void;
1028
+ /** Releases environment resources — kills the remaining background processes (core `Session.dispose`). Optional, idempotent. */
1029
+ dispose?(): void;
1030
+ }
1031
+ /** The underlying loader behind get-or-resume-or-heal. */
1032
+ interface SessionLoader {
1033
+ /**
1034
+ * Load a runtime Session from an index row: recover (with a Trace) or self-heal
1035
+ * rebuild (no Trace, session_id will change). Throws HttpError(409) for unrecoverable
1036
+ * cases such as a missing Workspace.
1037
+ */
1038
+ load(row: SessionRow): Promise<RuntimeSession>;
1039
+ }
1040
+ interface UsageRecorderLike {
1041
+ record(ctx: UsageContext, msg: OmniMessage): Promise<void>;
1042
+ }
1043
+ interface SessionManagerDeps {
1044
+ sessions: SessionIndex;
1045
+ channels: ChannelHub;
1046
+ loader: SessionLoader;
1047
+ /** Session-origin registry (session_meta is the single source of truth; subagent registration records the forwarded meta's source here). */
1048
+ sources: SessionOrigins;
1049
+ recorder: UsageRecorderLike;
1050
+ /** Automatic Session title generation (optional: not injected in tests or when disabled). */
1051
+ titles?: TitleNotifier;
1052
+ /** Error persistence (optional: without it, only logs — same as before this was wired up). */
1053
+ errors?: ErrorSink;
1054
+ log?: (line: string) => void;
1055
+ /** Goal run-state persistence (optional like `titles`: without it, goals run but leave no restorable record). */
1056
+ /**
1057
+ * Publishes a Session-scoped event on the user-level channel of everyone who can see the
1058
+ * Project. The audience lookup (owner + members) and the `user:<id>` channel binding stay in
1059
+ * the app layer, the same split the scheduler's `notify` uses — this class holds no Project
1060
+ * membership repos. Optional: without it only the per-Session channel is served, which is
1061
+ * what unit tests that don't wire it get.
1062
+ */
1063
+ notifyProjectUsers?: (projectId: string, event: ServerEvent) => void;
1064
+ /**
1065
+ * Clock for persisted timestamps (last_active_at). Injected like the other services' so a
1066
+ * stubbed clock moves this and `usage_records.ts` together — the pairing the legacy
1067
+ * backfill assumes when it reads MAX(ts) as a session's last activity.
1068
+ */
1069
+ now?: () => Date;
1070
+ /**
1071
+ * Called when a Task starts (or is queued) from a PERSON's input — a message typed in the
1072
+ * Web App, the CLI or a bound messaging bot, as opposed to one the harness injected
1073
+ * (`sender: "server"` / `"harness"`). Company mode uses it to reset the desk's @-chain
1074
+ * accounting: a person's message opens a fresh chain (hop 0), whatever mention last woke
1075
+ * the desk. Optional; unit tests that do not wire it get nothing.
1076
+ */
1077
+ onHumanInput?: (sessionId: string) => void;
1078
+ }
1079
+ /**
1080
+ * A queued message's original content, held while it waits in a queue so a recall (DELETE
1081
+ * /steer/:id, DELETE /follow-ups/:id) can hand it back to the composer for editing (#287).
1082
+ * `text` and `images` are the same strings the queued input already references (no extra
1083
+ * copy); files stay on disk in the Session scratchpad and are read back only at recall.
1084
+ */
1085
+ interface RecallStore {
1086
+ text: string;
1087
+ images: string[];
1088
+ files: RecallableFile[];
1089
+ }
1090
+ declare class SessionManager {
1091
+ private readonly deps;
1092
+ private readonly entries;
1093
+ /** Per-Session mutex (serializes get-or-load and status flips); auto-cleaned once the chain drains. */
1094
+ private readonly locks;
1095
+ private readonly log;
1096
+ /** Graceful-shutdown flag: once set, new Tasks/compactions are rejected (503). */
1097
+ private closed;
1098
+ /** Agents currently being deleted (key = agentKey): new Tasks/compactions are always rejected with 409 during this window. */
1099
+ private readonly deletingAgents;
1100
+ /** Sessions currently being deleted (guards against the entry/Trace file being rebuilt and reviving it inside the deletion race window). */
1101
+ private readonly deletingSessions;
1102
+ /** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes when a Project's credentials change. */
1103
+ private readonly agentGenerations;
1104
+ /** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
1105
+ private readonly liveTail;
1106
+ private readonly sweepTimer;
1107
+ /** Clock for persisted timestamps (see SessionManagerDeps.now); wall clock unless injected. */
1108
+ private readonly now;
1109
+ constructor(deps: SessionManagerDeps);
1110
+ statusOf(sessionId: string): SessionStatus;
1111
+ pendingApprovalCount(sessionId: string): number;
1112
+ pendingApprovals(sessionId: string): PendingApproval[];
1113
+ /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start. */
1114
+ pendingFollowUpCount(sessionId: string): number;
1115
+ /**
1116
+ * Background-task counts of a LOADED session, read live (see SessionInfo.backgroundTasks);
1117
+ * undefined when the session is not in the active table or nothing is running — the list
1118
+ * row and the single GET omit the field in both cases.
1119
+ */
1120
+ backgroundTasksOf(sessionId: string): SessionBackgroundTasks | undefined;
1121
+ /** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
1122
+ pendingSteeringOf(sessionId: string): PendingSteeringInfo[];
1123
+ /** Steering a finished run never delivered, waiting for the composer to take it back (see RuntimeEntry.returnedSteering). */
1124
+ returnedSteeringOf(sessionId: string): PendingSteeringInfo[];
1125
+ /**
1126
+ * Live subagent children of an ACTIVE runtime entry (empty when the session is not loaded
1127
+ * — after a restart there is no in-process child left to report, so empty is the truth).
1128
+ * Rides `task_state` events and the SSE subscribe snapshot; the panel renders child
1129
+ * running marks from this instead of parsing tool-output text.
1130
+ */
1131
+ subagentsOf(sessionId: string): BackgroundSubagentInfo[];
1132
+ /**
1133
+ * Host message to one child session — a user input on the child, whatever its state:
1134
+ * steering while it runs, a follow-up run while it is idle, a revival (resume-session
1135
+ * semantics) when it is no longer live (core `Session.sendToBackgroundSubagent`). The
1136
+ * parent runtime itself is loaded on demand (ensureEntry — the same get-or-resume path a
1137
+ * task uses), so messaging a child of a long-idle conversation works after a restart too;
1138
+ * the resume fallback names the child's owning Agent from its own session row, which must
1139
+ * belong to the parent's project.
1140
+ */
1141
+ sendToSubagent(sessionId: string, childSessionId: string, messages: OmniMessage[]): Promise<SubagentMessageOutcome>;
1142
+ /**
1143
+ * Sets a Session's thinking level on its loaded runtime (core `Session.thinkingLevel`,
1144
+ * plain state): the row's value is what the loader applies at load, so this only needs to
1145
+ * reach a runtime that is already in the active table — or a live child session, which
1146
+ * has an index row and a panel picker of its own but runs inside its parent's runtime.
1147
+ * The level is soft-limited: it applies from the Session's very next LLM request
1148
+ * (mid-context — the UI advises compacting first, since the change invalidates the
1149
+ * provider's cached context). No-op when nothing is loaded.
1150
+ */
1151
+ setThinkingLevel(sessionId: string, level: ThinkingLevelName): void;
1152
+ /** Host abort of one child session's current run (core `Session.abortBackgroundSubagentRun`); false when the parent runtime is not loaded, the child is unknown, or it is idle. */
1153
+ abortSubagentRun(sessionId: string, childSessionId: string): boolean;
1154
+ /** Queued follow-up tasks awaiting auto-start, as their display/recall info (id + content summary). */
1155
+ pendingFollowUpsOf(sessionId: string): PendingFollowUpInfo[];
1156
+ /**
1157
+ * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
1158
+ * streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
1159
+ * Empty when idle or when nothing is streaming. GET /messages attaches this (with a
1160
+ * channel cursor) so a client joining mid-stream can render the in-progress message.
1161
+ */
1162
+ liveFragments(sessionId: string): OmniMessage[];
1163
+ /**
1164
+ * The running Task's input messages as published at launch; empty when idle. The engine
1165
+ * writes these exact envelopes to the Trace only after the first run's bootstrap (MCP
1166
+ * connect + discovery), so GET /messages appends whichever of them the Trace read has
1167
+ * not caught up to yet — without this, a client rebuilding history during the connect
1168
+ * (the draft flow subscribes only after the input publish) loses the user's own message.
1169
+ */
1170
+ pendingInputs(sessionId: string): OmniMessage[];
1171
+ /** The running Task's streamed bootstrap records (see RuntimeEntry.pendingBootstrap); empty when idle. */
1172
+ pendingBootstrap(sessionId: string): OmniMessage[];
1173
+ /** Number of Sessions for this Agent that are currently running / compacting. */
1174
+ activeCountForAgent(projectId: string, agentId: string): number;
1175
+ /** Add a newly created Session to the active table (status idle), avoiding a redundant load on the next Task. */
1176
+ adopt(row: SessionRow, session: RuntimeSession): void;
1177
+ /**
1178
+ * Subscribes the background hooks on a runtime Session entering the active table — every
1179
+ * insertion path must call it (ensureEntry's loads, adopt's fresh creations):
1180
+ * - the idle-arrival signal for completion notices (mid-run arrivals are delivered inside
1181
+ * the run by core; this signal is the only trigger left when the session sits idle);
1182
+ * - live-forwarded background-subagent messages, published to the session channel (the
1183
+ * same feed SSE relays) and recorded for usage — a background child streams to the
1184
+ * frontend in real time past the launching turn's end, until its terminal state;
1185
+ * - subagent run-state changes, republished as `task_state` so the panel's running
1186
+ * marks track child rounds structurally instead of parsing tool-output text;
1187
+ * - background-task state changes (a command promoted or exited, a subagent promoted,
1188
+ * settling or released), re-counted and published as `session_background` on the user
1189
+ * channel so every Session list shows which rows still own background work.
1190
+ */
1191
+ private registerNoticeListener;
1192
+ /**
1193
+ * The entry-lifetime approval callback: the registry, the per-decision approval-mode
1194
+ * re-read, and the SSE escalation are all entry-scoped, so one instance serves every run
1195
+ * of the entry and the children's session-lifetime fallback sink alike.
1196
+ */
1197
+ private entryApprove;
1198
+ /** Publishes one live background-subagent message and records its usage (fire-and-forget; the child's own Trace is the durable record). */
1199
+ private forwardBackgroundMessage;
1200
+ /**
1201
+ * Bumps one Agent's config generation so every runtime built before it is discarded on
1202
+ * its next idle access and re-resumed via the loader (history is preserved through the
1203
+ * Trace). What warrants it is a Session-fixed fact changing under the runtime — the
1204
+ * Project's credentials (see invalidateProjectRuntimes). Agent State changes never do:
1205
+ * core reads them into the next model context itself. A Task already in flight is neither
1206
+ * aborted nor hot-swapped: it keeps the values it started with, and its entry is rebuilt
1207
+ * on the first access after it returns to idle (see ensureEntry).
1208
+ */
1209
+ invalidateAgentRuntimes(projectId: string, agentId: string): void;
1210
+ /**
1211
+ * After a Project's models/credentials change: invalidate every cached runtime in this
1212
+ * Project, so the next Task re-resumes with the new api_key / base_url. Same
1213
+ * effective-value semantics as invalidateAgentRuntimes — no hot swap into a Task already
1214
+ * in flight. Iterating the active table is complete here, not a shortcut: the generation
1215
+ * map only matters for runtimes that are already cached, and an Agent with no cached
1216
+ * entry builds fresh through the loader anyway.
1217
+ */
1218
+ invalidateProjectRuntimes(projectId: string): void;
1219
+ /**
1220
+ * Cheap, lock-free rehearsal of the 409/503 conditions startTask checks, throwing exactly the
1221
+ * same HttpErrors. **Advisory only**: it neither takes the Session lock nor loads an entry, so
1222
+ * a session that isn't in the active table reads as acceptable and a status change racing this
1223
+ * call is not caught — the authoritative check is still the one inside startTask.
1224
+ *
1225
+ * It exists so a caller that has irreversible work to do first (POST /tasks writes the
1226
+ * message's file attachments to disk) can find out about the ordinary "a Task is already
1227
+ * running" rejection before doing it, instead of undoing it afterwards.
1228
+ */
1229
+ assertCanAcceptTask(sessionId: string, opts?: {
1230
+ queueIfBusy?: boolean;
1231
+ }): void;
1232
+ /**
1233
+ * Run a metadata operation at an idle Session boundary under the same mutex as Task and
1234
+ * compaction starts. Session fork uses this to snapshot an append-only Trace without a Task
1235
+ * beginning between its status check and final read. An uncached Session is idle by definition;
1236
+ * unlike startTask this path deliberately does not load a heavyweight runtime.
1237
+ */
1238
+ atIdleBoundary<T>(sessionId: string, operation: () => Promise<T>): Promise<T>;
1239
+ /**
1240
+ * Start a Task: get-or-load → 409
1241
+ * mutual-exclusion check → publish the input messages first → drive run in the
1242
+ * background. Returns the current actual session_id (the new id after self-heal).
1243
+ * With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a
1244
+ * follow-up instead of 409: it auto-starts as an ordinary next task once the session
1245
+ * returns to idle (`queued: true` in the result; see startQueuedFollowUp).
1246
+ * `opts.recall` is that queued message's original
1247
+ * content — optional, because only the HTTP route knows the parts of it the input does
1248
+ * not carry (the pre-attachment text and where each file landed on disk); every other
1249
+ * caller's store is read off the input, so a queued follow-up is recallable and shows
1250
+ * its content whichever path queued it.
1251
+ */
1252
+ startTask(sessionId: string, input: OmniMessage[], opts?: {
1253
+ queueIfBusy?: boolean;
1254
+ recall?: RecallStore;
1255
+ }): Promise<{
1256
+ sessionId: string;
1257
+ queued: boolean;
1258
+ }>;
1259
+ /**
1260
+ * Start a goal run: like startTask, but the goal plugin composes the input and its stop
1261
+ * hook drives every later round inside this one `session.run` call — so the Session
1262
+ * stays `running` for the whole goal (every round), the existing abort endpoint
1263
+ * interrupts the entire loop and schedules queue behind it as usual. The plugin's
1264
+ * `user_prompt` hook (hooks run in core and nowhere else — `Session.runUserPromptHook`)
1265
+ * writes the Session's goal file and answers the round-1 protocol message; it runs under
1266
+ * the session lock AFTER the idle check, since it overwrites the goal file a running goal
1267
+ * is reading. Not installed (null) is the route's 409; an empty answer a 500. Round inputs
1268
+ * are yielded by core and published like any streamed message; progress additionally goes
1269
+ * out as goal_* server events (the run state itself is the goal file the plugin maintains).
1270
+ */
1271
+ startGoal(sessionId: string, args: {
1272
+ /** The user's own message(s) exactly as submitted — text and images, no wrapping. */
1273
+ messages: OmniMessage[];
1274
+ /** The user's own objective text (leading marker blocks stripped): what the plugin records, the goal_started event and the title material. */
1275
+ objective: string;
1276
+ budget: number;
1277
+ }): Promise<{
1278
+ sessionId: string;
1279
+ }>;
1280
+ /**
1281
+ * Taps a goal run's stream for `drive`: round boundaries become goal_round events —
1282
+ * round 1 from the seeded input (core never yields a run's initial input, so it is
1283
+ * counted here, where startGoal published it), later rounds from the stop-hook continues
1284
+ * core yields — and the goal hook's `stop` event becomes the
1285
+ * goal_finished server event. `used` is what the hook last recorded in its event's
1286
+ * `output` — the same number its budget check used — so the UI never shows a different
1287
+ * figure. A stream that ends without the hook's terminal event (a cut-off run, an
1288
+ * infrastructure failure) closes as `aborted`.
1289
+ */
1290
+ private goalStream;
1291
+ /** Closes a goal as aborted for the UI (stream cut off / infrastructure failure). */
1292
+ private finishAborted;
1293
+ /** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run. Caller holds the session lock and has verified idle. */
1294
+ private launchTask;
1295
+ /**
1296
+ * Auto-start of queued follow-ups: called from drive's finally whenever a Task or
1297
+ * compaction finishes (abort included — follow-ups are future tasks the user queued, so
1298
+ * an abort of the current run does not discard them). Re-validates everything under the
1299
+ * session lock (another task may have snuck in, the server may be shutting down, the
1300
+ * session may be getting deleted) and launches exactly one queued input — the next
1301
+ * finish picks up the one after, keeping order and one-at-a-time semantics.
1302
+ */
1303
+ private startQueuedFollowUp;
1304
+ /** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
1305
+ startCompact(sessionId: string): Promise<{
1306
+ sessionId: string;
1307
+ }>;
1308
+ /** Submit an approval decision; returns false if the pending approval doesn't exist (already decided/unknown). */
1309
+ decideApproval(sessionId: string, toolCallId: string, decision: "allow" | "deny"): boolean;
1310
+ /**
1311
+ * Mid-run steering: forward the message to the running Session (core delivers it between
1312
+ * turns as a standalone `[user_steering]` user message followed by its images — no SSE
1313
+ * event of its own; the messages arrive through the stream the drive loop already
1314
+ * publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
1315
+ * or the run finished in the race window — the caller falls back to submitting a normal
1316
+ * task, which carries the same text and images.
1317
+ *
1318
+ * `recall` is the queued message's original content: its summary (with a fresh id) is
1319
+ * mirrored on the entry and broadcast via `task_state` (and the SSE subscribe snapshot)
1320
+ * until the delivered `[user_steering]` message is observed on the stream — that is what
1321
+ * keeps the composer's "steering queued" hint, content included, alive across reloads —
1322
+ * and the content itself is what a recall (recallSteering) hands back to the composer.
1323
+ */
1324
+ steer(sessionId: string, input: OmniMessage[], recall: RecallStore): void;
1325
+ /**
1326
+ * Recall an undelivered steering message (#287): withdraw it from core's queue and the
1327
+ * display mirror, re-broadcast state, and hand back its original content for the composer
1328
+ * to restore. 409 `not_pending` when the entry is no longer queued — already delivered to
1329
+ * the model (core's `unsteer` says so authoritatively; the mirror can lag behind delivery
1330
+ * until the `[user_steering]` message is observed on the stream), the run exited, or the
1331
+ * id is unknown — all the same outcome for the caller: nothing left to take back.
1332
+ */
1333
+ recallSteering(sessionId: string, steerId: string): RecallStore;
1334
+ /**
1335
+ * Recall a queued follow-up task (#287): remove it from the queue before it auto-starts,
1336
+ * re-broadcast state, and hand back its original content (plus the thinking level it was
1337
+ * queued with). Being in the queue is the whole condition — every queued entry carries a
1338
+ * recall store (see QueuedFollowUp.recall), so no queued message is ever refused. 409
1339
+ * `follow_up_started` when the id is not in the queue: it already auto-started, or the
1340
+ * runtime was released and took the queue with it. Its own code, distinct from steering's
1341
+ * `not_pending`, because the two mean different things to the user — a follow-up became a
1342
+ * task of its own, a steering message reached the model mid-run.
1343
+ */
1344
+ recallFollowUp(sessionId: string, followUpId: string): {
1345
+ recall: RecallStore;
1346
+ };
1347
+ /**
1348
+ * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
1349
+ * the next retry immediately (the attempt counter is unchanged — the skipped wait does
1350
+ * not consume an extra attempt). Returns false as a benign no-op when the session has
1351
+ * no active runtime or no reconnect wait is in progress — timing races (the wait
1352
+ * elapsed right before the click) must not surface as errors. Mirrors the steer seam:
1353
+ * manager → RuntimeSession → core Session → engine.
1354
+ */
1355
+ retryNow(sessionId: string): boolean;
1356
+ /**
1357
+ * Interrupt the current Task/compaction: pending approvals converge to deny first,
1358
+ * then the AbortSignal fires. Returns false if nothing is in progress (the route
1359
+ * treats this as a 204 no-op).
1360
+ */
1361
+ abortTask(sessionId: string): boolean;
1362
+ /**
1363
+ * Background command processes of a LOADED session (empty when the entry is not in
1364
+ * the active table — a resumed entry starts with a fresh environment and can only
1365
+ * ever report an empty list, so nothing is resurrected just to answer a poll).
1366
+ */
1367
+ listProcesses(sessionId: string): BackgroundCommandInfo[];
1368
+ /**
1369
+ * Refreshes the listen-port probes behind the process list's `serviceUrl` (core
1370
+ * `Session.probeBackgroundCommandServices`): called by the processes route before
1371
+ * listing, so the first fetch already carries probed URLs. Bounded by core's own probe
1372
+ * timeout and TTL; a session that isn't loaded has no processes to probe.
1373
+ */
1374
+ probeProcessServices(sessionId: string): Promise<void>;
1375
+ /**
1376
+ * Hands one EXECUTING tool call of a loaded session back as a background task (core
1377
+ * `Session.detachToolCall`): the call closes with a registry handle, its work keeps
1378
+ * running, and the turn carries on. An unloaded session has nothing executing, so it
1379
+ * answers "not_running" like an unknown id.
1380
+ */
1381
+ detachToolCall(sessionId: string, toolCallId: string): ToolDetachResult;
1382
+ /** Kills one background command process of a loaded session; false when the session isn't loaded or the id is unknown. */
1383
+ killProcess(sessionId: string, processId: string): boolean;
1384
+ /**
1385
+ * Removes one EXITED background command entry from a loaded session's process list.
1386
+ * A running process is refused ("running") — stopping is killProcess's job, and a
1387
+ * bare removal must never surprise-signal a live process group. "not_found" covers
1388
+ * an unloaded session and an unknown id alike: the entry is gone either way. The
1389
+ * removal itself reuses the kill path — core's registry removal signals the (dead)
1390
+ * group defensively and drops the row, exactly like input_command's post-exit reap.
1391
+ * The only race, an entry observed running that exits before a retry, is benign:
1392
+ * the caller gets "running", refreshes, and sees the row exited on the next look.
1393
+ *
1394
+ * Note what leaves with the row: the registry entry owns the ManagedSession holding
1395
+ * that process's captured output, so after a removal input_command on the same
1396
+ * process_id answers "unknown process_id". Removal is a deliberate discard, not just
1397
+ * a list tidy-up — the Web App says so at the button and in the docs.
1398
+ */
1399
+ removeProcess(sessionId: string, processId: string): "removed" | "running" | "not_found";
1400
+ /**
1401
+ * Disposes a just-removed entry's runtime — after its in-flight drive (if any)
1402
+ * settles, so interrupt cleanup never races a dying environment. Deleting a Session /
1403
+ * Agent / Project is the one intent that must also end the background processes the
1404
+ * conversation started (a dev server surviving its deleted conversation is
1405
+ * unreachable from every UI, running forever).
1406
+ */
1407
+ private disposeRemoved;
1408
+ /**
1409
+ * Before deleting a Project, converge all its active runs and clear them out of the
1410
+ * active table. Returns the in-flight drive Promises of the affected entries: the
1411
+ * caller (deleteProject) should await them before removing the directory, so that
1412
+ * interrupt-cleanup Trace writes don't recreate the directory after deletion.
1413
+ */
1414
+ abortProject(projectId: string): Promise<void>[];
1415
+ /**
1416
+ * Before deleting an Agent, converge all its active runs and clear them out of the
1417
+ * active table (same semantics as abortProject). Also marks this Agent as "being
1418
+ * deleted": new Tasks/compactions entering during the deletion process are always
1419
+ * rejected with 409 (assertAgentNotDeleting), closing the race window where a new
1420
+ * task recreates the directory and revives an already-deleted Agent between the
1421
+ * abortAgent snapshot and the directory removal. The caller must call
1422
+ * endAgentDeletion once deletion finishes (success or failure).
1423
+ */
1424
+ beginAgentDeletion(projectId: string, agentId: string): Promise<void>[];
1425
+ endAgentDeletion(projectId: string, agentId: string): void;
1426
+ /**
1427
+ * Before deleting a single Session, converge its active run and clear it out of the
1428
+ * active table (same semantics as beginAgentDeletion). Also marks this Session as
1429
+ * "being deleted": new Tasks/compactions entering during the deletion process are
1430
+ * always rejected with 409 (assertSessionNotDeleting), closing the race window where
1431
+ * a new task recreates the entry and Trace file, reviving an already-deleted Session
1432
+ * between the abort snapshot and the file removal. The caller must call
1433
+ * endSessionDeletion once deletion finishes (success or failure). Returns the
1434
+ * in-flight drive Promise: the caller should await it before deleting the Trace file,
1435
+ * so cleanup writes don't recreate the file.
1436
+ */
1437
+ beginSessionDeletion(sessionId: string): Promise<void>[];
1438
+ endSessionDeletion(sessionId: string): void;
1439
+ /** Graceful shutdown: reject new tasks (503), interrupt all active runs, and wait for them to finish (default ≤5s). */
1440
+ shutdown(timeoutMs?: number): Promise<void>;
1441
+ /**
1442
+ * Active-table idle eviction: removes entries that are idle (idle status, no pending
1443
+ * approvals, no in-flight drive) and have been inactive past the timeout, releasing
1444
+ * the core Session's full in-memory history. This is purely memory reclamation: the
1445
+ * next access re-resumes via the loader, so correctness is unaffected. Lock-table
1446
+ * entries are auto-cleaned by withLock once their chain drains (including leftover
1447
+ * entries under the old id after self-heal). `now` / `idleMs` are injectable for
1448
+ * tests and timers.
1449
+ */
1450
+ sweepIdle(now?: number, idleMs?: number): void;
1451
+ private assertOpen;
1452
+ /** The Agent owning this Session is being deleted → 409 (guards against directory recreation inside the deletion race window). */
1453
+ private assertAgentNotDeleting;
1454
+ /** This Session is being deleted → 409 (guards against the entry/Trace being rebuilt and reviving it inside the deletion race window). */
1455
+ private assertSessionNotDeleting;
1456
+ private assertIdle;
1457
+ private generationOf;
1458
+ /** get-or-resume-or-heal: use directly on an active-table hit; otherwise load via the loader, updating the index's primary key on self-heal. */
1459
+ private ensureEntry;
1460
+ /**
1461
+ * Auto-start of queued background completion notices: called when core signals an
1462
+ * idle-session arrival, and from drive's finally for notices that raced a run's exit.
1463
+ * Revalidates under the session lock; only an idle entry launches, and only when the
1464
+ * queue is non-empty — a busy entry skips, because the running/queued task's engine
1465
+ * drains the same queue at its own boundaries (each notice is delivered exactly once).
1466
+ */
1467
+ private startBackgroundNoticeTask;
1468
+ /**
1469
+ * Drive the output stream in the background: publish each message + persist usage +
1470
+ * persist LLM/tool errors; on completion (including errors) resets to idle and pushes
1471
+ * the status. `titleSource` is passed only for Task runs (compaction doesn't generate
1472
+ * a title): its user text drives automatic title generation at run start.
1473
+ */
1474
+ private drive;
1475
+ /**
1476
+ * Register a subagent: persisted only when the origin message is session_meta
1477
+ * (agentId is derived from the agent_state path: `<…>/<agentId>/agent_state`).
1478
+ * **The title is left blank here** — the registration site in drive generates it
1479
+ * right away from the spawning prompt (fallback first words + LLM replacement), so
1480
+ * it never waits for the subagent's own output. Idempotent (children dedup +
1481
+ * insertOrIgnore); a subagent has its own Trace, so it's visible in both the list
1482
+ * and the trace view. On successful registration, the entry is put into `children`
1483
+ * and returned; a duplicate session_meta returns null.
1484
+ */
1485
+ private registerChildSession;
1486
+ /**
1487
+ * Runs one `sessions` row write for a driven run's bookkeeping (has_trace /
1488
+ * last_active_at), swallowing failures the same way the per-message `recorder.record`
1489
+ * call does: bookkeeping must never take down a run's lifecycle. The two call sites both
1490
+ * sit outside a covering try — the run-start write precedes drive's try block, the
1491
+ * run-end one lives in its finally — and the realistic failure (the DatabaseSync handle
1492
+ * closed by shutdown while a run outlived its drain window) throws ERR_INVALID_STATE.
1493
+ */
1494
+ private touchRow;
1495
+ private publishState;
1496
+ private publishEvent;
1497
+ /**
1498
+ * Re-counts a loaded session's background tasks and, when the counts moved, publishes
1499
+ * `session_background` on the user channel (the list's mark is a user-channel affair like
1500
+ * `session_state`; the open conversation reads the same row). A change is Session
1501
+ * activity for the idle sweep: the entry stays loaded for the usual idle window after its
1502
+ * last background task ends, so the finished process list can still be read. An entry
1503
+ * that already left the table (a delete disposing its runtime) publishes nothing — its
1504
+ * row is gone as well.
1505
+ */
1506
+ private publishBackgroundTasks;
1507
+ /** Serialize (mutually exclude) execution by sessionId; cleans up the lock-table entry once its chain drains (avoids unbounded growth). */
1508
+ private withLock;
1509
+ }
1510
+ declare const Sessions_base: (abstract new () => Pick<SessionManager, "steer" | "pendingApprovalCount" | "pendingFollowUpCount" | "statusOf" | "pendingApprovals" | "pendingSteeringOf" | "subagentsOf" | "sendToSubagent" | "abortSubagentRun" | "pendingFollowUpsOf" | "liveFragments" | "pendingInputs" | "pendingBootstrap" | "activeCountForAgent" | "invalidateAgentRuntimes" | "invalidateProjectRuntimes" | "assertCanAcceptTask" | "startTask" | "startGoal" | "startCompact" | "decideApproval" | "recallSteering" | "recallFollowUp" | "retryNow" | "abortTask" | "listProcesses" | "probeProcessServices" | "killProcess" | "removeProcess" | "abortProject" | "beginAgentDeletion" | "endAgentDeletion" | "beginSessionDeletion" | "endSessionDeletion" | "atIdleBoundary" | "shutdown" | "sweepIdle">) & (<C>(target: C, context: ClassDecoratorContext) => C);
1511
+ /** The session runtime: one entry per live Session, task mutex, approvals, streaming. */
1512
+ declare abstract class Sessions extends Sessions_base {
1513
+ }
1514
+ declare const SessionServiceIface_base: (abstract new () => Pick<SessionService, "hasTrace" | "toInfo" | "listSessions" | "sessionStats" | "createSession" | "latestTracePath" | "adoptUnmanagedTraceSessions">) & (<C>(target: C, context: ClassDecoratorContext) => C);
1515
+ declare abstract class SessionServiceIface extends SessionServiceIface_base {
1516
+ }
1517
+
1518
+ interface AgentConfigView {
1519
+ agentsMd: string;
1520
+ systemConfigYaml: string;
1521
+ config: AgentConfigDto;
1522
+ stateDir: string;
1523
+ }
1524
+ declare class AgentConfigService implements AgentConfig {
1525
+ private readonly paths;
1526
+ private get root();
1527
+ /** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
1528
+ exists(projectId: string, agentId: string): Promise<boolean>;
1529
+ requireExists(projectId: string, agentId: string): Promise<void>;
1530
+ /**
1531
+ * Read list-card metadata: name / description + tool count (sum of tools.builtin
1532
+ * and tools.mcpServers entries; MCP counted per server). Silently falls back to
1533
+ * empty / 0 if the file is corrupt.
1534
+ */
1535
+ readCardMeta(projectId: string, agentId: string): Promise<{
1536
+ name?: string;
1537
+ description?: string;
1538
+ toolCount: number;
1539
+ version: number;
1540
+ kernelOutdated: boolean;
1541
+ }>;
1542
+ /** Structured config view (matching the edit form's shape) + raw text + AGENTS.md + State path. */
1543
+ getConfig(projectId: string, agentId: string): Promise<AgentConfigView>;
1544
+ /**
1545
+ * PUT accepts any subset: only the provided keys are updated (parseDocument
1546
+ * preserves comments and untouched content); agentsMd is overwritten in full.
1547
+ * Numeric validation: >0 or -1; thinkingLevel / mode are validated as enums.
1548
+ */
1549
+ updateConfig(projectId: string, agentId: string, req: AgentConfigUpdateRequest): Promise<void>;
1550
+ /**
1551
+ * Overwrites system_config.yaml with the current code defaults (core's
1552
+ * resetSystemConfigToDefaults) — same semantics as updating an installed skill:
1553
+ * only the identity fields (name / description / version) survive; the system
1554
+ * prompt, max_turns, model/compaction settings and the tool list (incl. MCP
1555
+ * servers) become the current defaults. AGENTS.md, skills and the vault are
1556
+ * untouched.
1557
+ */
1558
+ resetConfig(projectId: string, agentId: string): Promise<void>;
1559
+ /**
1560
+ * Smart-merges the config up to the current defaults generation (core's applyKernelUpdate):
1561
+ * a settings tab still carrying a recorded generation's default follows the new defaults,
1562
+ * a tab the user changed is kept whole and reported; the config is stamped with the new
1563
+ * kernel version.
1564
+ * The destructive full-refresh alternative stays resetConfig.
1565
+ */
1566
+ kernelUpdate(projectId: string, agentId: string): Promise<AgentKernelUpdateResponse>;
1567
+ private applyConfigUpdate;
1568
+ /**
1569
+ * Inserts a feature's section placeholder into the Agent's prompt template — the explicit
1570
+ * adoption path mirroring the Memory tab's endpoint; nothing ever inserts automatically.
1571
+ * For skills/vault the insert is migration-first (core's helpers): a stored template still
1572
+ * carrying the legacy hardcoded section verbatim gets it replaced in place by the
1573
+ * placeholder, otherwise the placeholder is inserted before `# Environment`. Idempotent;
1574
+ * returns the refreshed config view (the route picks out the feature's DTO).
1575
+ */
1576
+ insertTemplatePlaceholder(projectId: string, agentId: string, feature: "vault" | "skills" | "schedules"): Promise<AgentConfigView>;
1577
+ /** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
1578
+ getVault(projectId: string, agentId: string): Promise<VaultResponse>;
1579
+ /**
1580
+ * PUT replaces the whole vault table (same semantics as models): keys absent from
1581
+ * the body are deleted; omitting value keeps the existing value (a new key must
1582
+ * provide a value). Key names are validated against shell environment variable
1583
+ * naming rules (same rule as core); deleting everything removes the whole
1584
+ * .vault.toml file.
1585
+ */
1586
+ updateVault(projectId: string, agentId: string, req: VaultUpdateRequest): Promise<VaultResponse>;
1587
+ }
1588
+
1589
+ declare const AgentConfig_base: (abstract new () => {
1590
+ exists(projectId: string, agentId: string): Promise<boolean>;
1591
+ requireExists(projectId: string, agentId: string): Promise<void>;
1592
+ readCardMeta(projectId: string, agentId: string): Promise<{
1593
+ name?: string;
1594
+ description?: string;
1595
+ toolCount: number;
1596
+ version: number;
1597
+ kernelOutdated: boolean;
1598
+ }>;
1599
+ getConfig(projectId: string, agentId: string): Promise<AgentConfigView>;
1600
+ updateConfig(projectId: string, agentId: string, req: AgentConfigUpdateRequest): Promise<void>;
1601
+ resetConfig(projectId: string, agentId: string): Promise<void>;
1602
+ kernelUpdate(projectId: string, agentId: string): Promise<AgentKernelUpdateResponse>;
1603
+ insertTemplatePlaceholder(projectId: string, agentId: string, feature: "vault" | "skills" | "schedules"): Promise<AgentConfigView>;
1604
+ getVault(projectId: string, agentId: string): Promise<VaultResponse>;
1605
+ updateVault(projectId: string, agentId: string, req: VaultUpdateRequest): Promise<VaultResponse>;
1606
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
1607
+ /** AgentConfig: the mechanism AgentConfigService implements. */
1608
+ declare abstract class AgentConfig extends AgentConfig_base {
1609
+ }
1610
+ declare const AgentLifecycle_base: (abstract new () => {
1611
+ listAgents(projectId: string): Promise<AgentListItem[]>;
1612
+ deleteAgent(projectId: string, agentId: string): Promise<void>;
1613
+ createAgent(projectId: string, agentId: string, name?: string, description?: string, skillNames?: readonly string[], directory?: {
1614
+ path: string;
1615
+ names: readonly string[];
1616
+ }, archive?: Buffer<ArrayBufferLike>): Promise<AgentListItem>;
1617
+ pluginVersion(projectId: string, agentId: string, pluginName: string): Promise<{
1618
+ installed: string | null;
1619
+ library: string | null;
1620
+ }>;
1621
+ updatePlugin(projectId: string, agentId: string, pluginName: string): Promise<void>;
1622
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
1623
+ /** AgentLifecycle: the mechanism AgentService implements. */
1624
+ declare abstract class AgentLifecycle extends AgentLifecycle_base {
1625
+ }
1626
+
1627
+ interface AgentListItem {
1628
+ agentId: string;
1629
+ name?: string;
1630
+ description?: string;
1631
+ createdAt?: string;
1632
+ /** Last config modification time: the later of system_config.yaml / AGENTS.md mtime. */
1633
+ updatedAt?: string;
1634
+ /** Tool count: number of tools.builtin + tools.mcpServers entries (MCP counted per server). */
1635
+ toolCount: number;
1636
+ /** Agent State version number (missing field treated as 1). */
1637
+ version: number;
1638
+ /** Whether the config's kernel stamp is behind the current defaults generation (missing stamp = outdated) — the list card's update hint. */
1639
+ kernelOutdated: boolean;
1640
+ /** Number of vault keys. */
1641
+ vaultKeyCount: number;
1642
+ /** Number of scheduled tasks (count of .toml files under schedule/, including invalid ones). */
1643
+ scheduleCount: number;
1644
+ /** Number of installed Skills (count of skills/<name>/ directories that contain a SKILL.md). */
1645
+ skillCount: number;
1646
+ /** Number of installed hook packages (hooks/<name>/ directories with a hooks.json). */
1647
+ hookCount: number;
1648
+ /** Installed skills and hook packages the library has a newer version of, by plugin, each carrying that library version. */
1649
+ pluginUpdates: PluginUpdateRef[];
1650
+ /** Number of memory topic files across every scope directory under memory/ (independent of the memory switch, like skillCount). */
1651
+ memoryCount: number;
1652
+ }
1653
+ declare class AgentService implements AgentLifecycle {
1654
+ private readonly paths;
1655
+ private get root();
1656
+ private readonly agents;
1657
+ private readonly agentConfig;
1658
+ private readonly snapshots;
1659
+ /** Union of DB index ∪ directory scan; unmanaged directory Agents are backfilled into the DB. */
1660
+ listAgents(projectId: string): Promise<AgentListItem[]>;
1661
+ /** Number of vault keys (falls back to 0 on read failure). */
1662
+ private vaultKeyCount;
1663
+ /** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
1664
+ private scheduleCount;
1665
+ /**
1666
+ * One pass over `skills/` and one over `hooks/`, answering the card's questions: how many
1667
+ * skills and hook packages are installed, and which of them the plugin library has moved
1668
+ * past — by plugin, since a plugin is what gets reinstalled to catch up.
1669
+ *
1670
+ * Only the HEAD of each SKILL.md is read, and that bound is load-bearing rather than a
1671
+ * micro-optimization: a preinstalled library is ~180 KB of SKILL.md across seventeen files
1672
+ * per Agent, so a Project of ten Agents would move ~2 MB through every `GET /agents` — and
1673
+ * the plugin page reloads that list after each update. The frontmatter block is at the head
1674
+ * by definition (the parser's regex is anchored there) and the largest one in the library is
1675
+ * under 1 KB, so {@link SKILL_HEAD_BYTES} covers it many times over; a block that somehow
1676
+ * overran it would be truncated, fail to parse, and read as an empty version — the same
1677
+ * fallback an unparseable file already takes, and one that sorts before every real version.
1678
+ *
1679
+ * The directory name is the identity (core's listings say so, and install/uninstall address
1680
+ * by it), so that — not a frontmatter `name` — is what the library is looked up by: a skill
1681
+ * by the plugin that ships it, a hook package by the plugin of the same name. A skill the
1682
+ * library does not carry (installed from a zip or a picked directory) has no library version
1683
+ * to be behind and is never an update. An unreadable skill degrades to "not installed" / "no
1684
+ * update": a card hint must not be the thing that makes the Agent list fail. The library is
1685
+ * the caller's, read once per listing rather than once per installed skill.
1686
+ */
1687
+ private installedPlugins;
1688
+ /**
1689
+ * Where one library plugin stands on one Agent: `installed` is the OLDEST version among the
1690
+ * plugin's installed skills and its installed hook package — the same "any component behind
1691
+ * the library is an update" rule {@link installedPlugins} draws the list cards' hints from —
1692
+ * and null when the Agent carries none of them; `library` is the version the library offers
1693
+ * now, null when the library has no such plugin. Only the head of each SKILL.md is read
1694
+ * (see SKILL_HEAD_BYTES), so the answer costs one small read per skill the plugin ships.
1695
+ *
1696
+ * The organization runtime asks this per employee per pass to keep company plugins current.
1697
+ * A skill the Agent does not carry — never installed, or removed by hand — counts for
1698
+ * nothing: what is compared is the oldest version the Agent actually has, so a plugin whose
1699
+ * skills are all current reads as current even where one of them is missing. Restoring a
1700
+ * deleted skill is the Agents page's business, not a pass's.
1701
+ */
1702
+ pluginVersion(projectId: string, agentId: string, pluginName: string): Promise<{
1703
+ installed: string | null;
1704
+ library: string | null;
1705
+ }>;
1706
+ /**
1707
+ * Reinstall one library plugin over an Agent's copy: the same whole-plugin update the
1708
+ * Agents page performs, through the same `installPlugin` writer, which overwrites every
1709
+ * skill the plugin ships and its hook package with library content. Throws 404
1710
+ * `unknown_plugin` when the library does not carry the name. Callers that may hold a cached
1711
+ * runtime for the Agent invalidate it afterwards — hook packages are bound when a core
1712
+ * Session is built, unlike skills, which are read from disk on demand.
1713
+ */
1714
+ updatePlugin(projectId: string, agentId: string, pluginName: string): Promise<void>;
1715
+ /** Number of memory topic files: regular `*.md` files (minus each scope's MEMORY.md index) summed over the scope directories under memory/ (0 if the directory doesn't exist). */
1716
+ private memoryCount;
1717
+ /** Last config modification time: the later of system_config.yaml and AGENTS.md mtime; omitted if neither is readable. */
1718
+ private configUpdatedAt;
1719
+ /**
1720
+ * Delete an Agent: the sole built-in Agent
1721
+ * default_agent (shared with the CLI, the default conversation Agent) cannot be
1722
+ * deleted; callers must first drain any active run via manager.abortAgent.
1723
+ * The directory is deleted recursively (including Trace), and the DB's
1724
+ * agents/sessions index rows are removed along with it; usage records are kept
1725
+ * (historical stats are unaffected).
1726
+ */
1727
+ deleteAgent(projectId: string, agentId: string): Promise<void>;
1728
+ /**
1729
+ * Create an Agent: the id is chosen by the creator (a semantic id, checked for
1730
+ * duplicates against both the DB and the directory within the Project — a 409
1731
+ * if taken, which naturally also blocks built-in Agent ids) → initialize State →
1732
+ * write name/description (name defaults to the id) → install the seed plugins.
1733
+ *
1734
+ * `pluginNames` are library plugin names picked at creation; they go through the same
1735
+ * `installPlugin` writer the plugin routes use, inside the same cleanup window as the rest
1736
+ * of initialization, so an Agent never survives with only part of its picked set. They are
1737
+ * resolved before anything is created, so an unknown name creates no directory at all.
1738
+ *
1739
+ * `archive` is an exported Agent State snapshot package: the fresh Agent is seeded from
1740
+ * it instead of the default template (explicit name/description override the package's
1741
+ * values, absent ones keep them — no id fallback). Mutually exclusive with seeding: the
1742
+ * package carries its own skills and hooks. An invalid package fails the whole creation
1743
+ * inside the same cleanup window, leaving no empty Agent behind.
1744
+ */
1745
+ createAgent(projectId: string, agentId: string, name?: string, description?: string, pluginNames?: readonly string[], directory?: {
1746
+ path: string;
1747
+ names: readonly string[];
1748
+ }, archive?: Buffer): Promise<AgentListItem>;
1749
+ }
1750
+
1751
+ declare const MessagingBindings_base: (abstract new () => {
1752
+ find(sessionId: string, channel: string): MessagingBindingRow | null;
1753
+ findEnabled(sessionId: string): MessagingBindingRow | null;
1754
+ listForSession(sessionId: string): MessagingBindingRow[];
1755
+ findEnabledByAccount(channel: string, accountId: string): MessagingBindingRow | null;
1756
+ listAll(): MessagingBindingRow[];
1757
+ upsert(args: {
1758
+ sessionId: string;
1759
+ channel: string;
1760
+ accountId: string;
1761
+ config: Record<string, unknown>;
1762
+ linePerMessage?: boolean;
1763
+ finalReplyOnly?: boolean;
1764
+ renderMarkdown?: boolean;
1765
+ }): MessagingBindingRow;
1766
+ setEnabled(sessionId: string, channel: string, enabled: boolean): void;
1767
+ recordChat(sessionId: string, channel: string, chatId: string, isDirect: boolean): void;
1768
+ recordInboundWatermark(sessionId: string, channel: string, messageId: string | null): void;
1769
+ delete(sessionId: string, channel: string): void;
1770
+ deleteSession(sessionId: string): void;
1771
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
1772
+ /** MessagingBindings: the mechanism MessagingBindingsRepo implements. */
1773
+ declare abstract class MessagingBindings extends MessagingBindings_base {
1774
+ }
1775
+
1776
+ interface MessagingBindingRow {
1777
+ sessionId: string;
1778
+ /** Messaging channel discriminator (`feishu` | `telegram` | `qq` | `wechat`). */
1779
+ channel: string;
1780
+ /** Channel-scoped bot/app identity (feishu: the app_id; telegram: the bot token's numeric id; qq: the App ID; wechat: the scanned bot id); never secret. */
1781
+ accountId: string;
1782
+ /** Channel-specific config document (feishu: appId/appSecret/baseDomain; telegram: botToken; qq: appId/appSecret; wechat: botId/botToken/baseUrl/userId). */
1783
+ config: Record<string, unknown>;
1784
+ /**
1785
+ * INTENT state: whether the binding should hold a live connection (the connection's
1786
+ * runtime status stays in memory). Saving credentials never flips it — only the
1787
+ * explicit state toggle does — and new bindings start disabled. Flipping it on is what
1788
+ * binds the account to this Session: at most one of a Session's rows is enabled, and at
1789
+ * most one row per `(channel, accountId)` is enabled across all Sessions (both
1790
+ * route-enforced).
1791
+ */
1792
+ enabled: boolean;
1793
+ /**
1794
+ * DELIVERY preference: send each non-blank line of a relayed assistant reply as its own
1795
+ * message instead of one message per reply. Off by default, and off reproduces the
1796
+ * original behaviour exactly. Ordinary saved state — the PUT owns it, like the config.
1797
+ */
1798
+ linePerMessage: boolean;
1799
+ /**
1800
+ * DELIVERY preference: relay only the LAST completed assistant message of a run, sent when
1801
+ * the run ends, instead of mirroring each of them as it completes. Off by default, and off
1802
+ * reproduces the original behaviour exactly. Independent of `linePerMessage`: with both set
1803
+ * that one final message is what gets split per line. Ordinary saved state, like the field
1804
+ * above.
1805
+ */
1806
+ finalReplyOnly: boolean;
1807
+ /**
1808
+ * DELIVERY preference: render a relayed assistant reply's Markdown in the channel's own
1809
+ * markup rather than sending its characters as written. ON by default — the model writes
1810
+ * Markdown, and relaying it raw is what this replaced. What survives differs per channel
1811
+ * (Telegram has no headings, lists or tables; QQ has no code or tables; Feishu has all of
1812
+ * them) and a rendering the channel refuses falls back to the plain source, so the setting
1813
+ * can cost formatting and never a message.
1814
+ */
1815
+ renderMarkdown: boolean;
1816
+ /**
1817
+ * Most recent inbound chat (null until the bot is messaged once). The channel's connector
1818
+ * mints this string and is the only thing that may read it: Telegram encodes the forum
1819
+ * topic into it, so it is not always a bare chat id. Pass it to `MessagingClient.sendText`,
1820
+ * never to a channel API directly.
1821
+ */
1822
+ lastChatId: string | null;
1823
+ /** Whether that chat is a direct chat; group chats prefer reply-to-message. */
1824
+ lastChatIsDirect: boolean;
1825
+ /**
1826
+ * Channel id of the most recently processed inbound message (null until one arrives).
1827
+ * The bridge's redelivery guard is in memory and dies with the process; this is the one
1828
+ * fact that survives it, so a channel replaying an already-processed event across a
1829
+ * restart is recognized rather than run again (see MessagingBridge's recentInbound).
1830
+ */
1831
+ lastInboundMessageId: string | null;
1832
+ createdAt: string;
1833
+ updatedAt: string;
1834
+ }
1835
+
1836
+ /**
1837
+ * The messaging-channel connector seam: what the MessagingBridge needs from one chat
1838
+ * platform (Feishu, Telegram, QQ and WeChat today; further channels implement the same interface
1839
+ * and register in app assembly). A connector owns everything channel-specific — credential
1840
+ * shape, wire protocol, event normalization — and hands the bridge a channel-neutral
1841
+ * view: a client for outbound sends and credential checks, and a long-lived event
1842
+ * connection delivering normalized inbound messages (text, images and files, each as far as
1843
+ * the channel actually carries them).
1844
+ *
1845
+ * Config documents are the repo's stored per-channel JSON (`MessagingBindingRow.config`);
1846
+ * every connector method validates its own shape and throws a readable error on a
1847
+ * malformed one — the bridge treats that like any other channel failure.
1848
+ */
1849
+ /** Known messaging channels (the DB stores the discriminator as text; unknown values are skipped defensively). */
1850
+ type MessagingChannel = "feishu" | "telegram" | "qq" | "wechat";
1851
+ /** One inbound image's bytes, once fetched, with the MIME type the bridge needs for its data URL. */
1852
+ interface MessagingInboundImageData {
1853
+ data: Buffer;
1854
+ /** e.g. `image/png` — the connector's best answer, from the channel's own type or the bytes. */
1855
+ mimeType: string;
1856
+ }
1857
+ /**
1858
+ * An image attached to an inbound message: a handle, not the bytes.
1859
+ *
1860
+ * The bytes are deliberately NOT eagerly downloaded by the connector. Most of what makes
1861
+ * an inbound message uninteresting is decided after the event is normalized — a
1862
+ * redelivery is dropped before anything else happens — and a channel that downloaded
1863
+ * first would pay a full image transfer for every replay. Fetching lazily also puts the
1864
+ * cap where the transfer is: `fetch` refuses anything over `maxBytes` rather than handing
1865
+ * the bridge something it would only discard, which is what keeps a 100MB attachment from
1866
+ * ever being resident in this process.
1867
+ */
1868
+ interface MessagingInboundImage {
1869
+ /**
1870
+ * Downloads the image. Two distinct failures, and the bridge answers them differently:
1871
+ * `MessagingMediaTooLargeError` (see media.ts) for anything past `maxBytes`, which the
1872
+ * user fixes by sending something smaller, and any other Error — carrying the channel's
1873
+ * OWN reason, since a permission the bot lacks and a network blip are not the same
1874
+ * problem — for a transfer that could not be made at all. That reason reaches the chat,
1875
+ * so a connector must keep credentials out of it (see telegram-api's fetchErrorText).
1876
+ */
1877
+ fetch(maxBytes: number): Promise<MessagingInboundImageData>;
1878
+ }
1879
+ /**
1880
+ * A non-image file attached to an inbound message: a handle, not the bytes, for exactly the
1881
+ * reasons MessagingInboundImage is one — a redelivery is dropped before anything is
1882
+ * transferred, and the cap rides into `fetch` so an oversized attachment is refused at the
1883
+ * byte that crosses it rather than buffered whole and measured afterwards. A file's ceiling
1884
+ * is the server's per-file attachment limit, which is far larger than an image's, so the
1885
+ * difference between refusing during and after the transfer is correspondingly larger.
1886
+ *
1887
+ * There is no MIME type on this seam. An image needs one because its bytes become a `data:`
1888
+ * URL inside the conversation; a file's bytes go to the Session scratchpad and reach the
1889
+ * model as a PATH, so the name's extension is the whole of what says what it is — and a
1890
+ * channel's declared media type is the sender's claim about a file nothing here parses.
1891
+ */
1892
+ interface MessagingInboundFile {
1893
+ /**
1894
+ * The sender's own file name, extension included — what the model sees at the end of the
1895
+ * `[attached file: …]` path, and what a refusal notice names. Channel text, so it is
1896
+ * neither trusted nor pre-sanitized here: the attachment writer maps it onto a name that
1897
+ * is safe on disk (see services/task-attachments.ts). A channel with no name for a file
1898
+ * says so with a plain fallback rather than inventing one, since a made-up extension
1899
+ * would tell the model the file is something it is not.
1900
+ */
1901
+ fileName: string;
1902
+ /** Downloads the file; the same two failure shapes MessagingInboundImage.fetch documents. */
1903
+ fetch(maxBytes: number): Promise<Buffer>;
1904
+ }
1905
+ /** One inbound chat message, normalized across channels. */
1906
+ interface MessagingInboundMessage {
1907
+ /**
1908
+ * Channel-scoped chat id, and the reply target for direct chats. Opaque to the bridge, for
1909
+ * the same reason `messageId` is: the connector both mints it here and consumes it in
1910
+ * `sendText`, so a channel whose replies need more routing context than a chat identity
1911
+ * encodes it (Telegram appends the forum topic). It is stored verbatim as the binding's
1912
+ * last known chat, so whatever is encoded here is what survives a restart.
1913
+ */
1914
+ chatId: string;
1915
+ /** Direct chat with the bot, or a group chat (groups prefer reply-to-message). */
1916
+ chatKind: "direct" | "group";
1917
+ /**
1918
+ * Channel-scoped id of the inbound message itself (the group reply target). Opaque to
1919
+ * the bridge: the connector both mints it here and consumes it in `replyText`, so a
1920
+ * channel whose native message ids are not globally unique encodes whatever context a
1921
+ * reply needs (Telegram packs `chatId:messageId`). It is also the bridge's inbound
1922
+ * dedupe key, so it must identify the MESSAGE and not the delivery: a channel that
1923
+ * redelivers one message must mint the same id both times. `""` opts the channel out of
1924
+ * deduplication entirely — the honest answer for a connector with no message identity,
1925
+ * and better than every message after the first reading as a duplicate.
1926
+ */
1927
+ messageId: string;
1928
+ /**
1929
+ * The message's plain text; null for anything that carries none (stickers, voice, …) — a
1930
+ * message with no text, no image and no file gets the not-supported notice. An image or a
1931
+ * file sent with a caption puts the caption here: the caption IS that message's text, so
1932
+ * a channel needs no second field for it and the bridge no second rule. A channel that
1933
+ * carries a caption on media it does NOT deliver must still report null, or the model
1934
+ * answers a question about an attachment it never received.
1935
+ */
1936
+ text: string | null;
1937
+ /**
1938
+ * Images attached to this message (absent or empty when there are none). Handles rather
1939
+ * than bytes — see MessagingInboundImage. A channel that carries several images in one
1940
+ * message lists them in the order the user sees them.
1941
+ */
1942
+ images?: readonly MessagingInboundImage[];
1943
+ /**
1944
+ * Non-image files attached to this message, in the order the user sees them (absent or
1945
+ * empty when there are none) — see MessagingInboundFile.
1946
+ *
1947
+ * Optional, like `images`, so a channel that delivers no files simply never populates it
1948
+ * rather than carrying an empty list through its whole normalizer. A message may carry
1949
+ * text, images and files together; the bridge composes one composer input out of whatever
1950
+ * is there.
1951
+ */
1952
+ files?: readonly MessagingInboundFile[];
1953
+ /** Sender display name when the channel's event carries one. */
1954
+ senderName?: string;
1955
+ }
1956
+ interface MessagingConnectorHandlers {
1957
+ onMessage(msg: MessagingInboundMessage): void | Promise<void>;
1958
+ /** The connection completed a handshake (may fire again after an automatic reconnect). */
1959
+ onReady?(): void;
1960
+ /** The connection failed and the channel gave up (or the initial connect failed). */
1961
+ onError?(err: unknown): void;
1962
+ }
1963
+ /** A live inbound event stream; `close` ends it (idempotent). */
1964
+ interface MessagingConnection {
1965
+ close(): void;
1966
+ }
1967
+ /**
1968
+ * A successful credential check's optional payload: whatever the probe learned that the test
1969
+ * endpoint's feedback can act on. Every member is independently optional — a channel reports
1970
+ * the ones its check happens to answer and omits the rest, so a check that learned nothing
1971
+ * beyond "these credentials work" returns an empty object.
1972
+ */
1973
+ interface MessagingAccountInfo {
1974
+ /** A short human-readable label of the account the credentials sign in as (Telegram: the bot's `@username`). */
1975
+ accountLabel?: string;
1976
+ /**
1977
+ * Whether this ACCOUNT is set up to receive ordinary messages in the groups it belongs to
1978
+ * — as opposed to only the ones a platform hands a bot by default.
1979
+ *
1980
+ * Telegram's privacy mode is the case that needs saying: it is on for every bot whose
1981
+ * owner has not turned it off in @BotFather, and under it `getUpdates` simply omits
1982
+ * everything in a group that is not a command addressed to this bot or a reply to one of
1983
+ * its messages. Nothing errors — the messages are never delivered, so a binding that works
1984
+ * perfectly in a direct chat looks dead in a group. It is one account-wide setting and
1985
+ * Telegram overrides it in any group the bot administers, so `false` says the account is
1986
+ * muted where it is an ordinary member, never that some particular group is silent. Absent
1987
+ * when the channel has no such notion, or reports nothing about it: unknown must never be
1988
+ * reported as a problem.
1989
+ */
1990
+ readsGroupMessages?: boolean;
1991
+ }
1992
+ /**
1993
+ * What a send had to give up in order to land, as a short readable phrase. The message DID
1994
+ * arrive — a send that did not throws — so this is not a failure: it says the message is
1995
+ * somewhere less right than it was addressed to, which is otherwise invisible from the
1996
+ * outside. A channel that always delivers as addressed returns nothing at all; Telegram is
1997
+ * the one that reports, a forum topic deleted under a live conversation sending the reply to
1998
+ * General instead.
1999
+ */
2000
+ type MessagingSendNote = string;
2001
+ /** One file on its way out to a chat: the bytes, plus the name the chat should show. */
2002
+ interface MessagingOutboundFile {
2003
+ /** Display name — the base name of the Workspace-relative path the reply mentioned. */
2004
+ fileName: string;
2005
+ data: Buffer;
2006
+ }
2007
+ /** How one outbound text should be rendered. Absent throughout means plain text. */
2008
+ interface MessagingSendOptions {
2009
+ /**
2010
+ * Read the text as Markdown and render it in whatever this channel's markup is.
2011
+ *
2012
+ * A REQUEST, not an instruction: the connector owns both what its channel can show and
2013
+ * what to do when the channel refuses the rendered form, and it must fall back to sending
2014
+ * this same text plainly rather than let a formatting failure cost the message. What
2015
+ * survives the conversion differs per channel and is documented at each renderer
2016
+ * (telegram-html.ts, feishu-card.ts, qq-markdown.ts).
2017
+ *
2018
+ * Only a relayed assistant message ever sets it. The fixed notices and the test message
2019
+ * carry no markup and are sent as they are written, so a bug in a renderer can never
2020
+ * reach the one message a user sends to check whether the binding works at all.
2021
+ */
2022
+ markdown?: boolean;
2023
+ }
2024
+ /** Outbound half of one bound account. Every method throws on failure with a readable reason. */
2025
+ interface MessagingClient {
2026
+ /** Credential check (used by the test endpoint); resolving means the config signs in. */
2027
+ checkCredentials(): Promise<MessagingAccountInfo | null>;
2028
+ /** Sends a text message into a chat by chat id; resolves a MessagingSendNote when it degraded. */
2029
+ sendText(chatId: string, text: string, opts?: MessagingSendOptions): Promise<MessagingSendNote | void>;
2030
+ /** Replies a text message to a specific inbound message (threads correctly in group chats). */
2031
+ replyText(messageId: string, text: string, opts?: MessagingSendOptions): Promise<MessagingSendNote | void>;
2032
+ /**
2033
+ * Sends a picture into a chat, so a chart the Agent drew arrives as something the reader
2034
+ * can see rather than as a download. Channels that need an upload step first do it here —
2035
+ * the bridge holds bytes and a name, never a channel's file handle.
2036
+ */
2037
+ sendImage(chatId: string, file: MessagingOutboundFile): Promise<void>;
2038
+ /** Sends any other file into a chat as an attachment. */
2039
+ sendFile(chatId: string, file: MessagingOutboundFile): Promise<void>;
2040
+ }
2041
+ interface MessagingChannelConnector {
2042
+ readonly channel: MessagingChannel;
2043
+ /**
2044
+ * How many outbound messages this channel will accept in answer to ONE inbound message,
2045
+ * or undefined where no such limit exists (Feishu and Telegram both send freely).
2046
+ *
2047
+ * A channel that declares one enforces it itself — the connector is the only place that
2048
+ * knows what the platform rejects and how to combine messages so nothing is lost. What
2049
+ * the bridge does with the number is narrower: it caps the one-message-per-line split at
2050
+ * it, because that option's own ceiling is sized for a channel with no such budget and
2051
+ * would otherwise ask for more messages than the channel can ever deliver.
2052
+ */
2053
+ readonly replyBudget?: number;
2054
+ /** Builds the outbound client for one stored config (throws on a malformed document). */
2055
+ createClient(config: Record<string, unknown>): Promise<MessagingClient>;
2056
+ /**
2057
+ * Opens the inbound event stream for one stored config. Resolves as soon as the
2058
+ * connection is constructed and connecting — lifecycle arrives via the handlers
2059
+ * (`onReady` / `onError`), because channels reconnect on their own and a single promise
2060
+ * cannot carry a lifecycle.
2061
+ */
2062
+ connect(config: Record<string, unknown>, handlers: MessagingConnectorHandlers): Promise<MessagingConnection>;
2063
+ }
2064
+
2065
+ /**
2066
+ * Auth middleware: `Authorization: Bearer <local API token>` -> the built-in admin, or
2067
+ * session cookie -> auth_sessions row -> user; either way the user is injected into c.var.
2068
+ *
2069
+ * The Bearer path authenticates the boot's local API token (`<root>/api-token`, see
2070
+ * auth/api-token.ts) as the admin — the CLI's and agents' machine-local credential; it
2071
+ * applies to every route behind this middleware, SSE endpoints included (the CLI
2072
+ * consumes SSE via fetch with headers). A Bearer header that does not match fails the
2073
+ * request rather than falling back to the cookie: silently downgrading a wrong explicit
2074
+ * credential would mask misconfiguration.
2075
+ *
2076
+ * Accessing a protected API while logged out -> 401 `{error:{code:"unauthorized"}}`.
2077
+ * CSRF: SameSite=Lax plus a Content-Type an HTML form cannot forge (see jsonOnlyWrites).
2078
+ * That guard stays for Bearer requests too — the CLI always sends application/json.
2079
+ */
2080
+
2081
+ /** Hono env: variables injected by the auth middleware. */
2082
+ type AppEnv = {
2083
+ Variables: {
2084
+ user: UserRow;
2085
+ /** How the current session was established — see {@link SessionVia}. */
2086
+ sessionVia: SessionVia;
2087
+ };
2088
+ };
2089
+
2090
+ /** Minimal dependency on SessionManager (eases test doubles; mirrors ScheduleTaskRunner). */
2091
+ interface MessagingTaskRunnerShape {
2092
+ statusOf(sessionId: string): string;
2093
+ startTask(sessionId: string, input: OmniMessage[],
2094
+ /**
2095
+ * `recall` is what a queued follow-up hands back when the user withdraws it in the Web
2096
+ * App. It is optional because the manager derives one from the input for every caller
2097
+ * that has nothing to add; the inbound path supplies its own only when it wrote file
2098
+ * attachments, whose on-disk paths the input does not carry (see inboundRecallStore).
2099
+ */
2100
+ opts: {
2101
+ queueIfBusy: boolean;
2102
+ recall?: RecallStore;
2103
+ }): Promise<{
2104
+ sessionId: string;
2105
+ queued: boolean;
2106
+ }>;
2107
+ }
2108
+ /**
2109
+ * Minimal dependency on the sessions index: existence checks for reconcile/cascade, the
2110
+ * Session's Project/Agent so a messaging failure can be filed under them (see recordError),
2111
+ * and the one field an outbound file needs — the Workspace a mentioned path resolves against.
2112
+ */
2113
+ interface MessagingSessionIndex {
2114
+ findById(sessionId: string): {
2115
+ projectId: string;
2116
+ agentId: string;
2117
+ workspace: string;
2118
+ } | null;
2119
+ }
2120
+ /**
2121
+ * Minimal dependency on the Workspace file service. Narrow, but deliberately NOT a
2122
+ * reimplementation: both methods carry that service's containment rules (a path that leaves
2123
+ * the Workspace — by `..`, by an absolute path, or through a symlink — is refused there,
2124
+ * and reads happen on the canonical path). A second resolver living in the bridge is
2125
+ * exactly the kind of thing that ends up one security fix behind.
2126
+ */
2127
+ interface MessagingWorkspaceFiles {
2128
+ /**
2129
+ * The subset of `rels` that exist as regular files inside the Workspace, each with the
2130
+ * time it was last written — which is what separates a file this run produced from one
2131
+ * the reply merely named (see deliverFiles).
2132
+ */
2133
+ statExistingWithMtime(workspace: string, rels: string[]): Promise<{
2134
+ rel: string;
2135
+ mtimeMs: number;
2136
+ }[]>;
2137
+ /** Reads a file, at most `maxBytes` of it. */
2138
+ read(workspace: string, rel: string, options?: {
2139
+ maxBytes?: number;
2140
+ }): Promise<{
2141
+ data: Buffer;
2142
+ fileName: string;
2143
+ }>;
2144
+ }
2145
+ interface MessagingBridgeDeps {
2146
+ repo: MessagingBindings;
2147
+ sessions: MessagingSessionIndex;
2148
+ files: MessagingWorkspaceFiles;
2149
+ /**
2150
+ * The data root, so an inbound file can be written into the Session scratchpad the web
2151
+ * composer's uploads land in (`scratchpadDir(root, projectId, agentId)/<sessionId>`,
2152
+ * deleted with the Session). The bridge holds the root rather than a pre-resolved
2153
+ * directory because the Project and Agent are per Session, not per binding.
2154
+ */
2155
+ root: string;
2156
+ /**
2157
+ * The attachment limits in force right now. A function, not a snapshot: the per-file and
2158
+ * per-message ceilings are admin-settable and read fresh per request everywhere else, so
2159
+ * a change has to reach the next inbound message without a restart.
2160
+ */
2161
+ attachmentLimits: () => AttachmentLimits;
2162
+ channels: ChannelHub;
2163
+ runner: MessagingTaskRunner;
2164
+ /** One connector per channel; a stored binding whose channel has no connector is skipped with an error record. */
2165
+ connectors: readonly MessagingChannelConnector[];
2166
+ errors: ErrorSink;
2167
+ log?: (line: string) => void;
2168
+ now?: () => number;
2169
+ /** Test hook: the pace between a per-line reply's messages (default MESSAGING_LINE_DELAY_MS; tests collapse it to zero). */
2170
+ lineDelayMs?: number;
2171
+ /** Test hook: one binding's inbound image budget (default MESSAGING_INBOUND_IMAGE_BUDGET_BYTES). */
2172
+ inboundImageBudgetBytes?: number;
2173
+ }
2174
+ declare class MessagingBridge {
2175
+ private readonly deps;
2176
+ private readonly entries;
2177
+ /**
2178
+ * Processed inbound ids per BINDING (`sessionId:channel`), deliberately not per
2179
+ * connection: re-saving an enabled binding restarts its connector, and a message
2180
+ * already turned into a Task must not run again because the connector is new.
2181
+ *
2182
+ * This map dies with the process; `messaging_bindings.last_inbound_message_id` is the
2183
+ * half that does not, and `connect` folds it back in here so the two are one memory
2184
+ * rather than two guards. The row holds ONE id — the last message the bridge finished
2185
+ * with — which is what Feishu's WebSocket resume needs and all it needs: that stream
2186
+ * replays events it never saw acknowledged, and the SDK acknowledges each one only
2187
+ * after our handler returns, so at most the one in flight when the process ended is
2188
+ * still owed. Telegram redelivers nothing across a restart — its poller advances
2189
+ * `offset` past an update before handing it over, and a fresh connection drains with
2190
+ * `getUpdates({offset: -1})`, discarding whatever was sent while nobody was connected —
2191
+ * so on that channel the row is still written and still seeded, and simply never has a
2192
+ * replay to catch.
2193
+ */
2194
+ private readonly recentInbound;
2195
+ /** Inbound image budgets, keyed like recentInbound: per BINDING, surviving a reconnect. */
2196
+ private readonly imageBudgets;
2197
+ private readonly connectors;
2198
+ private readonly now;
2199
+ private readonly log;
2200
+ private readonly lineDelayMs;
2201
+ private readonly inboundImageBudgetBytes;
2202
+ constructor(deps: MessagingBridgeDeps);
2203
+ /**
2204
+ * Server startup: connect every ENABLED binding (disabled ones keep their credentials
2205
+ * and stay dark; at most one per Session is enabled). A binding whose Session no longer
2206
+ * exists (deleted while this server was down, or by a bulk Agent/Project delete that
2207
+ * bypassed the per-session cascade) is reconciled away instead of connected.
2208
+ */
2209
+ start(): Promise<void>;
2210
+ /** App dispose: close every connection; bindings persist for the successor's start(). */
2211
+ stop(): void;
2212
+ /**
2213
+ * Align the live connection with the stored intent: an enabled binding exists →
2214
+ * (re)connect with its CURRENT config, none → disconnect. The state toggle calls this
2215
+ * after flipping intent, and a credential save calls it only while the saved binding is
2216
+ * enabled — the restart that keeps stored config and live connection from diverging.
2217
+ */
2218
+ sync(sessionId: string): Promise<void>;
2219
+ /** Route DELETE: disconnect (when this channel holds the connection) and drop the row. No-op when unbound. */
2220
+ unbind(sessionId: string, channel: string): void;
2221
+ /** Session-delete cascade: disconnect and drop every channel's config. */
2222
+ unbindSession(sessionId: string): void;
2223
+ /**
2224
+ * One channel's runtime status (only the connected channel is ever anything but
2225
+ * disconnected), plus what this connection has actually seen: when a message last arrived,
2226
+ * what last failed after one did, and the last connection failure even after it recovered.
2227
+ * A binding can be `connected` with nothing wrong and still never receive anything, and
2228
+ * that combination is invisible without them.
2229
+ */
2230
+ statusOf(sessionId: string, channel: string): MessagingRuntimeStatus;
2231
+ /**
2232
+ * Credential probe for the test endpoints: ok/error with latency, never a throw. A
2233
+ * channel whose check identifies the account (Telegram: the bot's @username) passes
2234
+ * that label through for the route's success feedback, along with anything else the
2235
+ * probe learned that the user should act on (see MessagingAccountInfo).
2236
+ */
2237
+ testCredentials(channel: string, config: Record<string, unknown>): Promise<{
2238
+ ok: boolean;
2239
+ latencyMs?: number;
2240
+ accountLabel?: string;
2241
+ readsGroupMessages?: boolean;
2242
+ error?: string;
2243
+ }>;
2244
+ /**
2245
+ * Test-message endpoints: a short fixed text to the binding's last known chat. The
2246
+ * route has already established that the binding and its chat exist; a race that
2247
+ * removed either since surfaces as the send failing.
2248
+ */
2249
+ sendTestMessage(row: MessagingBindingRow): Promise<void>;
2250
+ private connectorFor;
2251
+ private connect;
2252
+ private disconnect;
2253
+ /** Every timestamp this bridge writes comes from the injected clock, never from `Date` directly. */
2254
+ private nowIso;
2255
+ /** Status write, guarded against a stale entry (replaced by a newer connect). */
2256
+ private setStatus;
2257
+ /**
2258
+ * A connection failure, put in the three places one belongs: the live `error` state, the
2259
+ * copy kept across recovery, and the error table.
2260
+ *
2261
+ * Behind the same stale-entry guard `setStatus` uses, applied to all three — a connector
2262
+ * callback firing after a newer connect replaced this entry is reporting an attempt that was
2263
+ * already abandoned, and neither its status writes (which nothing reads) nor its record
2264
+ * (which would be filed against a binding since reconfigured) belong to the binding as it
2265
+ * now stands.
2266
+ */
2267
+ private recordConnectionFailure;
2268
+ /**
2269
+ * One error record for a messaging failure, filed under the Session's Project and Agent.
2270
+ *
2271
+ * The attribution is load-bearing, not decoration: `GET /api/projects/:id/usage/errors`
2272
+ * selects by project, and a record with none is "unattributed" — served only to admins
2273
+ * (`includeGlobalErrors`), so an ordinary member could never see a failure of their own
2274
+ * binding anywhere. The lookup uses the sessions index the bridge already holds; a row
2275
+ * deleted since leaves the record unattributed rather than dropping it.
2276
+ */
2277
+ private recordError;
2278
+ /**
2279
+ * A failure AFTER an inbound message was accepted: recorded like any other, and also put
2280
+ * on the binding's runtime status so the panel can say it. The error table is a Project-wide
2281
+ * dashboard nobody visits to debug one bot; the binding's own panel is where the question
2282
+ * is asked.
2283
+ *
2284
+ * A later success does not clear it. An intermittent failure — a group admin who revokes
2285
+ * send rights for a minute, a rate limit — would otherwise be erased by the next ordinary
2286
+ * message and never be seen at all, which is the same blind spot `lastConnectionError`
2287
+ * exists to close. The panel states `at` so a stale one reads as stale.
2288
+ */
2289
+ private recordDeliveryFailure;
2290
+ /**
2291
+ * A send that landed somewhere less right than it was addressed to. Not a failure — the
2292
+ * message arrived — so it stays out of `lastDeliveryError` and off the panel, and goes to
2293
+ * the log, which is where "the replies moved to General a while ago" is answerable at all.
2294
+ */
2295
+ private noteSend;
2296
+ private clientFor;
2297
+ /**
2298
+ * Has this binding already processed this message? A duplicate is a complete no-op —
2299
+ * ahead of the chat record and the text-only reply, not just the Task start, because a
2300
+ * replayed sticker would otherwise answer with the notice twice.
2301
+ *
2302
+ * Nothing downstream is idempotent: `startTask` with `queueIfBusy` appends to the
2303
+ * follow-up queue unconditionally, and a queued input is published to the Session
2304
+ * channel, so one redelivery becomes two runs in the chat AND two messages in the Web
2305
+ * App. A connector that mints no id opts out rather than having every message after the
2306
+ * first read as a duplicate.
2307
+ */
2308
+ private isRedelivery;
2309
+ /** Get-or-create one binding's processed-id memory (see recentInbound). */
2310
+ private inboundMemoryOf;
2311
+ private onInbound;
2312
+ /**
2313
+ * Turns a message carrying attachments into one Task, or resolves the notice to answer
2314
+ * the chat with when it could not be made whole.
2315
+ *
2316
+ * All-or-nothing across BOTH kinds, and in this order: the images are downloaded first,
2317
+ * then the files, and only then is anything written to disk. A refusal on either side
2318
+ * stops the message — a message is one thing the user sent, and half of it reaching the
2319
+ * Agent is not a partial success but a misleading one — and refusing before the write
2320
+ * means a rejected message leaves nothing behind in the scratchpad to clean up.
2321
+ */
2322
+ private startAttachedTask;
2323
+ /**
2324
+ * Lands this message's files in the Session scratchpad and folds their path lines into the
2325
+ * input. Returns the input untouched when there are none, so a message with no files
2326
+ * creates no directory.
2327
+ *
2328
+ * A failure here is NOT answered in the chat, unlike a refused download: a write that
2329
+ * fails is a fault of this server (a full disk, a scratchpad that is not writable), the
2330
+ * same class as a Session that cannot load, and it reaches the user through
2331
+ * recordDeliveryFailure and the binding's status line. The chat is told about the things
2332
+ * the person in it can act on.
2333
+ */
2334
+ private writeInboundFiles;
2335
+ /**
2336
+ * Downloads the message's files into composer attachments, or the notice to answer with
2337
+ * when one of them could not be delivered.
2338
+ *
2339
+ * The caps are the server's attachment limits — the very numbers an authenticated
2340
+ * composer upload answers to — because these files land in exactly the same place, the
2341
+ * Session scratchpad, and are read back the same way, through the model's bounded file
2342
+ * tools. Both byte budgets apply:
2343
+ *
2344
+ * - the per-file ceiling rides into each `fetch`, so an oversized attachment is refused at
2345
+ * the byte that crosses it and is never resident in this process;
2346
+ * - the per-message total is spent as the batch is collected, each later file's cap
2347
+ * shrinking to what is left, so a message carrying several cannot add up past it either.
2348
+ *
2349
+ * The per-message file COUNT is deliberately not enforced: attachment-limits.ts calls it a
2350
+ * composer-usability bound rather than a resource one, the byte budgets being what actually
2351
+ * bound memory and disk — and a chat message carries one file today on both channels.
2352
+ *
2353
+ * There is no rolling per-binding budget like the image one, because the cost it exists to
2354
+ * prevent is not paid here: an inline image is written into the Trace and re-read whole on
2355
+ * every history page and every resume, while a file is bytes on disk that nothing re-reads
2356
+ * and that are deleted with the Session.
2357
+ *
2358
+ * A refusal for size is the user's to fix and is not an error record — they sent something
2359
+ * too big and the chat says so. A failure is somebody's fault (a scope the bot was never
2360
+ * granted, most likely) and lands in the error log with the channel's reason, so it is
2361
+ * diagnosable from the dashboard and not only from a chat bubble.
2362
+ */
2363
+ private inboundFileAttachments;
2364
+ /**
2365
+ * Downloads the message's images into `image_url` input parts, or the notice to answer
2366
+ * with when one of them could not be delivered.
2367
+ *
2368
+ * The cap is the server's inline-image ceiling — the same number the web composer's
2369
+ * uploads answer to, because these images land in exactly the same place (the
2370
+ * conversation, and from there the Trace, which is read back whole on every resume). It
2371
+ * happens to match Telegram's own 20MB download ceiling for bots.
2372
+ *
2373
+ * A refusal for size is the user's to fix and is not an error record — they sent
2374
+ * something too big and the chat says so. A failure is somebody's fault (a scope the bot
2375
+ * was never granted, most likely) and lands in the error log with the channel's reason,
2376
+ * so it is diagnosable from the dashboard and not only from a chat bubble.
2377
+ */
2378
+ private inboundImageParts;
2379
+ /** This binding's rolling image budget, created on first use (see InboundImageBudget). */
2380
+ private imageBudgetFor;
2381
+ /** Reply to the inbound message itself: threaded reply in groups, plain send in direct chats. */
2382
+ private replyInbound;
2383
+ /** Channel tap: each main-conversation completed assistant message + the run-state flips. */
2384
+ private observe;
2385
+ /**
2386
+ * Queues one completed assistant message for the bound chat — or holds it back.
2387
+ *
2388
+ * Appended to the entry's send chain rather than started here: a run can complete several
2389
+ * messages within milliseconds, and two sends racing would reach the chat in the wrong
2390
+ * order. Returns immediately either way, so a slow channel never blocks the Session's
2391
+ * stream handling.
2392
+ *
2393
+ * A binding with `finalReplyOnly` set sends nothing from here: the message replaces
2394
+ * whatever this run was already holding, and the last one standing is delivered at the
2395
+ * run's idle edge (see onTaskState). The preference is read per message off the stored row,
2396
+ * as every delivery preference is, and no run ever sees it change: each of the three PUT
2397
+ * handlers calls `sync` on an enabled binding, `sync` reconnects, and `connect` opens with a
2398
+ * disconnect and a fresh entry — so a save made mid-run discards whatever was held at that
2399
+ * moment along with the rest of this entry's memory (see heldReply).
2400
+ */
2401
+ private relay;
2402
+ /**
2403
+ * Whether this binding holds a run's intermediate messages back. A row that cannot be read
2404
+ * at all is deliberately not decided here: relaying is the default, and the same read
2405
+ * inside deliverReply then records the failure rather than letting it pass as a preference.
2406
+ */
2407
+ private finalReplyOnly;
2408
+ /**
2409
+ * Where this entry's traffic would go right now: null before any chat is known, and null
2410
+ * when the row cannot be read at all — a caller holding null falls back to reading it again
2411
+ * at delivery time, which is where a failure to read is recorded rather than swallowed.
2412
+ */
2413
+ private replyTargetOf;
2414
+ private onTaskState;
2415
+ /**
2416
+ * Sends one relayed assistant message to the bound chat, in chunks under the channel's cap.
2417
+ * Which messages arrive here is the caller's business — every completed message of the run,
2418
+ * or its last one alone when the binding holds the rest (`finalReplyOnly`) — and nothing
2419
+ * below is affected by the difference.
2420
+ *
2421
+ * `at` is where to put it. A message relayed as it completes passes none and goes to the
2422
+ * chat the row names now, which is where the conversation is. A held reply passes the target
2423
+ * captured when its run started (see onTaskState), because it leaves at the run's END: a
2424
+ * second user writing in another forum topic meanwhile would otherwise take delivery of a
2425
+ * whole answer to a question they never asked, quoted onto their own message.
2426
+ *
2427
+ * A send failure is recorded, never thrown, so the chain behind it keeps
2428
+ * moving — and so do the messages behind it in THIS reply: one refused message (a 429 on
2429
+ * the third line of twelve) must not abandon the rest, which would leave a reply stopping
2430
+ * mid-sentence with nothing in the chat to say why. A binding with `linePerMessage` set
2431
+ * sends one message per non-blank line instead of one per reply (see splitReplyLines,
2432
+ * capped at the channel's own `replyBudget` where it declares one), paced
2433
+ * MESSAGING_LINE_DELAY_MS apart so the burst stays inside the channel's per-chat
2434
+ * allowance — chunking, threading and ordering are identical either way, and with the flag
2435
+ * off the send sequence is unchanged. In a group the run's
2436
+ * first outbound chunk threads onto the inbound message and
2437
+ * everything after it is a plain send: the reply-to relation names which message is being
2438
+ * answered, and one of them says it — repeating it per message and per chunk stacks quote
2439
+ * headers over the whole conversation.
2440
+ *
2441
+ * A binding with `renderMarkdown` set relays the model's Markdown as formatting rather
2442
+ * than as characters. The bridge only carries the REQUEST: the conversion is the
2443
+ * connector's, because what a channel can show and what it does with a rendering the
2444
+ * channel refuses are both channel-shaped (see MessagingSendOptions). What is not
2445
+ * channel-shaped is where a reply too long for one message may be cut, and that is why the
2446
+ * chunker changes with the flag — a cut through a code fence or an emphasis run costs the
2447
+ * construct everywhere, and on a strict parser costs the whole message's formatting.
2448
+ */
2449
+ private deliverReply;
2450
+ /**
2451
+ * Sends the files THIS RUN produced and its reply mentioned, after its text.
2452
+ *
2453
+ * `replyText` is what the run actually SENT, not everything it said: a binding with
2454
+ * `finalReplyOnly` set delivered one message, so that message is the whole of what this
2455
+ * scans (see onTaskState). A file named only in a held-back working note has nothing in
2456
+ * the chat to explain its arrival.
2457
+ *
2458
+ * `at` is where those words went, for the one caller whose text is not addressed to the live
2459
+ * chat (again the held reply). Files follow their words: a picture landing in a forum topic
2460
+ * where nothing named it is the same defect as one arriving unmentioned.
2461
+ *
2462
+ * Two filters, and both are load-bearing. The reply having said the name is what picks
2463
+ * the one output that was the point out of the dozen a run writes. The file being newer
2464
+ * than the run's start is what keeps that from becoming an exfiltration primitive: the
2465
+ * reply is steerable by whoever is in the chat, and on a mention-only rule a group member
2466
+ * who asks "what is in secrets.json?" gets the file uploaded by the very refusal that
2467
+ * declined to paste it. Containment (the Workspace and nothing narrower) lives in the
2468
+ * file service either way — see MessagingWorkspaceFiles.
2469
+ *
2470
+ * A file the run did not write is dropped SILENTLY, and so is a name matching no file at
2471
+ * all: the rule reads prose, so a reply that mentions the config it read — or describes a
2472
+ * `hello-world.md` it never wrote — is the ordinary case, and announcing those puts an
2473
+ * error-shaped line under correct answers. Both are logged instead. What IS named in the
2474
+ * chat is a file that exists and whose upload failed: there the reply promised something
2475
+ * that then did not arrive, which is the one case where silence reads as broken.
2476
+ *
2477
+ * Always plain sends, never a threaded reply: a run that mentions a file has by
2478
+ * definition already sent the text that mentions it, and that message took the group's
2479
+ * one reply-to. Nothing here throws — a batch that fails is recorded, and a single file
2480
+ * that fails does not stop the ones behind it.
2481
+ */
2482
+ private deliverFiles;
2483
+ /**
2484
+ * Names one file that did not make it, in the chat.
2485
+ *
2486
+ * Every other way a file does not arrive is already visible there — over the byte cap,
2487
+ * past the count cap, an inbound refusal. A failed upload reached `error_records` alone,
2488
+ * which the person in the chat cannot open, so the feature just looked broken. A missing
2489
+ * permission gets its own wording: it is the one failure they can fix themselves, in
2490
+ * about ten seconds, given the scope name and the console link.
2491
+ *
2492
+ * Its own try/catch: a chat that will not take the notice must not take the rest of the
2493
+ * batch down with it, and the upload failure behind it is already recorded.
2494
+ */
2495
+ private noteFileFailure;
2496
+ /** One mirrored file: read under the outer ceiling, then sent as a picture or an attachment. */
2497
+ private deliverOneFile;
2498
+ /** One-line notice (approval waiting) to the last known chat; silent before one exists. */
2499
+ private deliverNotice;
2500
+ /**
2501
+ * Where this entry's outbound traffic goes, with a client for it: null when no chat is
2502
+ * known yet (nothing mirrors before the first inbound message) and null when the binding
2503
+ * or its client could not be read at all, which is recorded like the send it stands in for.
2504
+ */
2505
+ private sendTarget;
2506
+ /**
2507
+ * Whether this entry still holds its Session's connection — asked at the head of every
2508
+ * step of the send chain, the way `observe` asks it of every event.
2509
+ *
2510
+ * The chain outlives what fed it. A run's last messages and the files behind them are
2511
+ * queued on it, and `stop()` — App dispose, a hot swap — drops the entry and takes the
2512
+ * database with it while they are still waiting their turn. A step that ran anyway would
2513
+ * read a closed database, and it would fail again inside its own error recording, leaving
2514
+ * the chain rejected with nothing left to catch it.
2515
+ */
2516
+ private live;
2517
+ /** The pace between a per-line reply's messages; zero (tests) waits for nothing. */
2518
+ private pace;
2519
+ }
2520
+ declare const MessagingTaskRunner_base: (abstract new () => MessagingTaskRunnerShape) & (<C>(target: C, context: ClassDecoratorContext) => C);
2521
+ /** What the bridge needs of the session runtime — declared at the consumer. */
2522
+ declare abstract class MessagingTaskRunner extends MessagingTaskRunner_base {
2523
+ }
2524
+ declare const Messaging_base: (abstract new () => Pick<MessagingBridge, "start" | "stop" | "statusOf" | "sync" | "unbind" | "unbindSession" | "testCredentials" | "sendTestMessage">) & (<C>(target: C, context: ClassDecoratorContext) => C);
2525
+ /** The chat-channel bridge: bindings, delivery, status. */
2526
+ declare abstract class Messaging extends Messaging_base {
2527
+ }
2528
+ interface MessagingSlots {
2529
+ /** A channel connector: which channel it speaks (static), and the connector (code). */
2530
+ connectors: Slot<{
2531
+ channel: "feishu" | "telegram" | "qq" | "wechat";
2532
+ }, MessagingChannelConnector>;
2533
+ }
2534
+
2535
+ declare const Http_base: (abstract new () => {
2536
+ fetch(request: Opaque<"Request", Request>): Promise<Opaque<"Response", Response>>;
2537
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
2538
+ /** The assembled business surface: one request in, one response (or a decline) out. */
2539
+ declare abstract class Http extends Http_base {
2540
+ }
2541
+ interface HttpSlots {
2542
+ /**
2543
+ * A route group. `auth: "user"` mounts it behind the cookie gate, `"none"` in front of
2544
+ * it; `order` is the mount position (a stable number, since Hono matches in order). The
2545
+ * code half is the group's Hono app.
2546
+ */
2547
+ routes: Slot<{
2548
+ prefix: string;
2549
+ auth: "user" | "none";
2550
+ order: number;
2551
+ }, Opaque<"Hono", Hono<AppEnv>>>;
2552
+ }
2553
+
2554
+ /**
2555
+ * GET /api/contributions — the web slots' contributions, as data. What a pushed platform
2556
+ * or an installed plugin adds to the web app arrives here; the app merges it with its
2557
+ * own module.json and renders what it has a renderer for.
2558
+ */
2559
+
2560
+ declare const WebShell_base: (abstract new () => {
2561
+ /** Every contribution to the web slots, by slot, in module order. */
2562
+ contributions(): ContributionsResponse;
2563
+ }) & (<C>(target: C, context: ClassDecoratorContext) => C);
2564
+ /**
2565
+ * The frontend's slots, declared on the SERVER so a server module (or a plugin) can
2566
+ * contribute a page, an Agent settings tab or a Session tab as manifest data. The web
2567
+ * app reads them back through GET /api/contributions and renders the ones whose
2568
+ * renderer it knows: a `builtin` name from its own registry, or an `iframe`. No code
2569
+ * crosses this boundary — only data.
2570
+ */
2571
+ declare abstract class WebShell extends WebShell_base {
2572
+ }
2573
+ interface WebShellSlots {
2574
+ /** A page: its route, whether it sits in the main nav, whether it is admin-only. */
2575
+ pages: {
2576
+ key: string;
2577
+ path: string;
2578
+ nav: "main" | "none";
2579
+ admin: boolean;
2580
+ renderer: RendererRef;
2581
+ };
2582
+ /** A tab on the Agent settings page. */
2583
+ agentTabs: {
2584
+ key: string;
2585
+ order: number;
2586
+ renderer: RendererRef;
2587
+ };
2588
+ /** A tab beside a Session's chat (a workflow UI lives here). */
2589
+ sessionTabs: {
2590
+ key: string;
2591
+ renderer: RendererRef;
2592
+ };
2593
+ }
2594
+
2595
+ export { AgentConfigService, AgentService, Http, type HttpSlots, Messaging, type MessagingSlots, Sandbox, type SandboxSlots, SessionServiceIface, Sessions, Terminals, WebShell, type WebShellSlots };