@agentconnect.md/daemon 1.0.0-rc.246 → 1.0.0-rc.248

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.
@@ -0,0 +1,1953 @@
1
+ import { _ as record, a as array, b as unknown, c as discriminatedUnion, d as literal, m as object, n as _enum, o as boolean, p as number, v as string, y as union } from "./schemas-CQkO9hC5.js";
2
+ import { l as resolveRoot, r as defaultAgentsDir, t as configPath } from "./paths-DEcUO8lC.js";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ //#region ../protocol/dist/envelope.js
7
+ /**
8
+ * The protocol envelope — every frame, both directions, is wrapped in this.
9
+ * Mirrors daemon-cp-ws-protocol.md §1.1.
10
+ *
11
+ * `payload` is left as `unknown` here and validated by the per-type schema in
12
+ * `frames/*` via `FRAME_SCHEMAS[type]` (see `frame.ts`). This two-step parse is
13
+ * what lets the codec answer an unknown `type` with `UNKNOWN_FRAME` (a REP)
14
+ * instead of a hard close — forward-compat, protocol §1.
15
+ */
16
+ const Envelope = object({
17
+ v: literal(1),
18
+ id: string().uuid(),
19
+ ts: string().datetime(),
20
+ type: string(),
21
+ corr: string().uuid().optional(),
22
+ payload: unknown()
23
+ });
24
+ object({
25
+ epoch: number().int(),
26
+ agentId: string().uuid().optional(),
27
+ launchId: string().uuid().optional()
28
+ });
29
+ /** The all-zero UUID used when a frame is malformed past the point of reading `id`. */
30
+ const NIL_UUID = "00000000-0000-0000-0000-000000000000";
31
+ /**
32
+ * Builds the envelope schema for one frame `type` — the `type` literal plus the
33
+ * typed payload — so a wire's discriminated union infers `payload` precisely.
34
+ * Shared by the daemon↔CP union (`frame.ts`) and the relay wire unions
35
+ * (`frames/relay-cp.ts` / `frames/relay-daemon.ts`), which are deliberately
36
+ * SEPARATE unions over the same envelope shape (shared-bot-relay.md §8).
37
+ */
38
+ function frameSchema(type, payload) {
39
+ return object({
40
+ v: literal(1),
41
+ id: string().uuid(),
42
+ ts: string().datetime(),
43
+ type: literal(type),
44
+ corr: string().uuid().optional(),
45
+ payload
46
+ });
47
+ }
48
+ //#endregion
49
+ //#region ../protocol/dist/frames/auth.js
50
+ /**
51
+ * Auth & identity — protocol §3.1 / §3.2.
52
+ *
53
+ * `auth` is the first frame after the socket opens. `auth/ok` carries the
54
+ * minted `sessionEpoch` (the global fencing token), heartbeat cadence, and the
55
+ * resume verdict.
56
+ */
57
+ const AuthReq = object({
58
+ apiKey: string(),
59
+ daemonId: string().uuid().optional(),
60
+ machineId: string().uuid().optional(),
61
+ attestation: string().optional(),
62
+ agentVersion: string(),
63
+ resume: object({ lastEpoch: number().int() }).optional()
64
+ });
65
+ const AuthOk = object({
66
+ daemonId: string().uuid(),
67
+ sessionEpoch: number().int(),
68
+ heartbeatSec: number().int(),
69
+ serverTime: string().datetime(),
70
+ webAppUrl: string().optional(),
71
+ orgSlug: string().optional(),
72
+ resume: object({ accepted: boolean() }).optional()
73
+ });
74
+ //#endregion
75
+ //#region ../protocol/dist/frames/route.js
76
+ /**
77
+ * Routing & orchestration (C→D control) — protocol §5.
78
+ *
79
+ * `SessionKey` is the canonical session primitive shared across route/*,
80
+ * agent/*, and event/session. Its canonical string form is
81
+ * `${platform}:${channel}:${thread ?? "-"}`.
82
+ */
83
+ const Platform = _enum([
84
+ "slack",
85
+ "telegram",
86
+ "webchat",
87
+ "discord"
88
+ ]);
89
+ const SessionKey = object({
90
+ platform: Platform,
91
+ channel: string(),
92
+ thread: string().optional()
93
+ });
94
+ /** Trigger-matching rule for a binding (protocol §5.1). */
95
+ const BindRule = object({ match: discriminatedUnion("kind", [
96
+ object({ kind: literal("mention") }),
97
+ object({ kind: literal("dm") }),
98
+ object({
99
+ kind: literal("keyword"),
100
+ value: string()
101
+ }),
102
+ object({ kind: literal("auto") })
103
+ ]) });
104
+ const RouteAssign = object({
105
+ sessionKey: SessionKey,
106
+ agentId: string().uuid(),
107
+ workspaceId: string().uuid(),
108
+ bindRules: array(BindRule).default([])
109
+ });
110
+ const RouteAssignAck = object({
111
+ ok: boolean(),
112
+ sessionKey: SessionKey,
113
+ reason: string().optional()
114
+ });
115
+ const RouteUpdate = object({
116
+ routingEpoch: number().int(),
117
+ rules: array(object({
118
+ match: unknown(),
119
+ agentId: string().uuid()
120
+ }))
121
+ });
122
+ /** Graceful scale-down / rebalance — protocol §5.3. */
123
+ const Drain = object({
124
+ scope: union([
125
+ object({
126
+ kind: literal("agent"),
127
+ agentId: string().uuid()
128
+ }),
129
+ object({ kind: literal("daemon") }),
130
+ object({
131
+ kind: literal("session"),
132
+ sessionKey: SessionKey
133
+ })
134
+ ]),
135
+ deadline: string().datetime()
136
+ });
137
+ const DrainProgress = object({
138
+ remaining: number().int(),
139
+ drained: array(SessionKey)
140
+ });
141
+ const DrainDone = object({ released: array(SessionKey) });
142
+ //#endregion
143
+ //#region ../protocol/dist/frames/cron.js
144
+ /**
145
+ * Cron sinks to the daemon (D5) — protocol §5.4.
146
+ *
147
+ * A cron periodically triggers ONE AGENT with a synthetic prompt (`trigger`) to
148
+ * carry out some work. The CP owns the definition; the daemon owns firing +
149
+ * last-run persistence, so crons fire even when the CP is down. On receipt the
150
+ * daemon persists the def into the owning agent's `agent.json` `crons[]` (the
151
+ * single source of truth, same model as integrations) — surviving a restart
152
+ * with the CP down.
153
+ *
154
+ * `target` is OPTIONAL output routing: when present, the daemon posts the
155
+ * trigger as a real message in that channel and the agent's session replies in
156
+ * its thread; when absent the fire is headless (the agent works with no
157
+ * platform output).
158
+ */
159
+ const CronTarget = object({
160
+ platform: _enum([
161
+ "slack",
162
+ "telegram",
163
+ "discord"
164
+ ]).default("slack"),
165
+ channel: string(),
166
+ integrationId: string().uuid().optional()
167
+ });
168
+ const CronUpsert = object({
169
+ cronId: string().uuid(),
170
+ agentId: string().uuid(),
171
+ schedule: string(),
172
+ target: CronTarget.optional(),
173
+ trigger: string(),
174
+ enabled: boolean().default(true)
175
+ });
176
+ const CronRemove = object({ cronId: string().uuid() });
177
+ /**
178
+ * `cron/report` (D→C EVT, fire-and-forget) — one CP-owned cron fired. The
179
+ * daemon stamps the fire into its local store first (it stays authoritative,
180
+ * §5.4) and reports it here so the console's `lastRunAt` converges; the CP
181
+ * upsert is latest-wins, so the daemon re-asserting its stored stamps on
182
+ * reconnect (fires while the CP was unreachable) is idempotent and can never
183
+ * regress the value. Hand-authored (no-origin) crons are never reported.
184
+ *
185
+ * Two reports per fire, keyed by `(cronId, firedAt)`: the FIRE report (no
186
+ * `status`) opens the run, and the COMPLETION report (with `status` + outcome
187
+ * fields) closes it once the dispatched turn ends — feeding the console's
188
+ * per-schedule run history. A completion without a prior fire report (CP was
189
+ * down at fire time) still creates the run row.
190
+ */
191
+ const CronRunStatus = _enum(["success", "failed"]);
192
+ const CronReport = object({
193
+ cronId: string().uuid(),
194
+ agentId: string().uuid(),
195
+ firedAt: string().datetime(),
196
+ status: CronRunStatus.optional(),
197
+ durationMs: number().int().nonnegative().optional(),
198
+ sessionId: string().optional(),
199
+ reason: string().optional()
200
+ });
201
+ /**
202
+ * `cron/run` (C→D REQ → ack) — fire one CP-owned cron NOW (console "Run now").
203
+ * The daemon accepts (`ok:true`) and runs the fire asynchronously — outcome
204
+ * arrives as normal `cron/report`s; `ok:false` when it holds no such cron.
205
+ */
206
+ const CronRunNow = object({ cronId: string().uuid() });
207
+ //#endregion
208
+ //#region ../protocol/dist/frames/secrets.js
209
+ /**
210
+ * Secrets (C5 ↔ D10) — protocol §6.
211
+ *
212
+ * Lease-based, no plaintext on the wire or in PG. Every frame carries a
213
+ * REFERENCE to a Vault/KMS path, never the secret material itself.
214
+ */
215
+ const SecretsRequest = object({ scope: object({
216
+ platform: Platform,
217
+ workspaceId: string().uuid()
218
+ }) });
219
+ const SecretsGrant = object({
220
+ leaseId: string().uuid(),
221
+ scope: object({
222
+ platform: string(),
223
+ workspaceId: string().uuid()
224
+ }),
225
+ ref: string(),
226
+ ttl: number().int(),
227
+ renewBeforeSec: number().int()
228
+ });
229
+ const SecretsRenew = object({ leaseId: string().uuid() });
230
+ const SecretsRevoke = object({
231
+ leaseId: string().uuid(),
232
+ reason: string()
233
+ });
234
+ /** 🅼 Direct-to-store upload/download grant — protocol §3.2 / frame #25. */
235
+ const ScopeAttestation = object({
236
+ machineId: string().uuid(),
237
+ scope: _enum([
238
+ "attachment.put",
239
+ "attachment.get",
240
+ "facts.put"
241
+ ]),
242
+ resourceRef: string(),
243
+ jws: string(),
244
+ exp: string().datetime()
245
+ });
246
+ //#endregion
247
+ //#region ../protocol/dist/frames/agent.js
248
+ /**
249
+ * Agent lifecycle (protocol §4.4, §7.4, §8).
250
+ *
251
+ * There is no CP→daemon prompt-delivery frame: the daemon prompts an agent from
252
+ * its own ingress (platform adapters, and webchat over its own `webchat/*`
253
+ * frames), never the CP. The old `agent/prompt` + per-agent `seq` machinery was
254
+ * reserved infrastructure with no live caller and has been removed.
255
+ */
256
+ /**
257
+ * Where the agent runs. Two modes; the **path is always daemon-generated** —
258
+ * never specified by the caller (UX picks the mode, the machine owns the dir).
259
+ *
260
+ * - `scratch`: a fresh empty working dir on the machine, no repo.
261
+ * - `github`: the daemon clones `gitRepo` @ `branch` and runs the agent in
262
+ * `agentDir` (a subdir of the repo, repo-root if omitted). **Multiple agents
263
+ * may share one repo** — they differ by `agentDir`, so the repo is not an
264
+ * owned entity, just shared config on each agent.
265
+ */
266
+ const AgentWorkspace = discriminatedUnion("mode", [object({ mode: literal("scratch") }), object({
267
+ mode: literal("github"),
268
+ gitRepo: string(),
269
+ branch: string().default("main"),
270
+ agentDir: string().optional(),
271
+ gitCredential: _enum(["github-app"]).optional()
272
+ })]);
273
+ /**
274
+ * MCP-server name reserved for the daemon's own injected stdio bridge (its
275
+ * platform tools). A config-defined or agent-enabled server under this name
276
+ * would collide with the bridge entry at ACP `session/new`, so the daemon
277
+ * strips it and the CP rejects it in `AgentSpec.mcpServers` at the API edge.
278
+ */
279
+ const RESERVED_MCP_SERVER_NAME = "agentconnect";
280
+ /**
281
+ * The editable agent definition the CP owns and the daemon needs to run it:
282
+ * prompt + runtime selection. Raft ships this in the launch config and the
283
+ * daemon synthesizes the system prompt locally; `description` IS the prompt.
284
+ */
285
+ const AgentSpec = object({
286
+ name: string(),
287
+ description: string().optional(),
288
+ runtime: string().optional(),
289
+ model: string().nullable().optional(),
290
+ reasoningEffort: string().nullable().optional(),
291
+ executionMode: string().optional(),
292
+ outputMode: _enum([
293
+ "low",
294
+ "medium",
295
+ "high"
296
+ ]).optional(),
297
+ fastMode: boolean().optional(),
298
+ permissionMode: string().nullable().optional(),
299
+ pause: boolean().optional(),
300
+ workspace: AgentWorkspace.optional(),
301
+ env: record(string(), string()).optional(),
302
+ memory: object({ provider: _enum([
303
+ "native",
304
+ "managed",
305
+ "external"
306
+ ]) }).optional(),
307
+ mcpServers: array(string()).default([])
308
+ });
309
+ const AgentLaunch = object({
310
+ agentId: string().uuid(),
311
+ runtime: string(),
312
+ workspaceId: string().uuid(),
313
+ capabilities: array(string()),
314
+ spec: AgentSpec,
315
+ mode: _enum(["long_lived", "per_turn"]).default("long_lived")
316
+ });
317
+ /**
318
+ * Live agent CRUD (C→D): the console edited an agent's spec; push it so a
319
+ * running daemon reloads without waiting for the next launch. `agent/remove`
320
+ * tears the agent down. Deleting an agent never relaunches it.
321
+ */
322
+ const AgentUpsert = object({
323
+ agentId: string().uuid(),
324
+ spec: AgentSpec
325
+ });
326
+ const AgentRemove = object({ agentId: string().uuid() });
327
+ const AgentLaunched = object({
328
+ agentId: string().uuid(),
329
+ launchId: string().uuid(),
330
+ acpSessionId: string().optional(),
331
+ startedAt: string().datetime(),
332
+ runtime: string()
333
+ });
334
+ const AgentStop = object({
335
+ agentId: string().uuid(),
336
+ launchId: string().uuid(),
337
+ reason: string()
338
+ });
339
+ const AgentActivity = object({
340
+ agentId: string().uuid(),
341
+ launchId: string().uuid(),
342
+ state: _enum([
343
+ "thinking",
344
+ "tool_call",
345
+ "awaiting_permission",
346
+ "idle"
347
+ ]),
348
+ ts: string().datetime()
349
+ });
350
+ const AgentScopeDenied = object({
351
+ agentId: string().uuid(),
352
+ launchId: string().uuid(),
353
+ capability: string()
354
+ });
355
+ //#endregion
356
+ //#region ../protocol/dist/frames/integration.js
357
+ /**
358
+ * Platform integration distribution (C→D) — the Slack "install" flow.
359
+ *
360
+ * The Control Plane is the source of truth for platform integrations and pushes
361
+ * them to the daemon that owns the integration's agent (`integration/upsert`, and
362
+ * the reconcile snapshot `RegisterOk.integrations[]`). The daemon opens the Socket
363
+ * Mode connection from the delivered config (see slack/connection.ts).
364
+ *
365
+ * SECURITY: `integration/upsert` and `RegisterOk.integrations[]` carry PLAINTEXT
366
+ * platform tokens (botToken/appToken). These payloads MUST NEVER be logged — no
367
+ * body dump on decode error, no register/ok snapshot debug dump. The daemon
368
+ * persists them into the owning agent's local `agent.json` (same trust boundary
369
+ * as hand-authored agents, which already keep tokens there) so integrations
370
+ * survive a restart with the CP down.
371
+ *
372
+ * `signingSecret` is intentionally absent: Socket Mode authenticates with the
373
+ * app-level token, so the daemon never needs a signing secret.
374
+ */
375
+ /** Trigger match — mirrors the daemon BindRuleConfig.match (agents/agent-schema.ts). */
376
+ const BindMatch = discriminatedUnion("kind", [
377
+ object({ kind: literal("mention") }),
378
+ object({ kind: literal("dm") }),
379
+ object({
380
+ kind: literal("keyword"),
381
+ value: string()
382
+ }),
383
+ object({ kind: literal("auto") })
384
+ ]);
385
+ /** One channel/thread trigger binding — mirrors the daemon BindRuleConfig. */
386
+ const IntegrationBindRule = object({
387
+ channel: string().optional(),
388
+ thread: string().optional(),
389
+ match: BindMatch
390
+ });
391
+ /** The Slack config the daemon needs to open + route a socket (no signingSecret). */
392
+ const IntegrationSlackConfig = object({
393
+ botToken: string(),
394
+ appToken: string(),
395
+ allowedUserIds: array(string()).default([]),
396
+ bindRules: array(IntegrationBindRule).default([])
397
+ });
398
+ /**
399
+ * The Telegram config the daemon needs to open long-polling + route (grammY).
400
+ * Telegram has a SINGLE BotFather HTTP token — no app-level token and no signing
401
+ * secret (long-polling authenticates every getUpdates call with the bot token).
402
+ */
403
+ const IntegrationTelegramConfig = object({
404
+ botToken: string(),
405
+ allowedUserIds: array(string()).default([]),
406
+ bindRules: array(IntegrationBindRule).default([])
407
+ });
408
+ /**
409
+ * The Discord config the daemon needs to open the Gateway + route (discord.js).
410
+ * Discord authenticates the Gateway with a SINGLE bot token — no Slack-style
411
+ * app-level token and no signing secret. `applicationId` is public metadata (the
412
+ * client id for the OAuth2 bot-invite URL); it is not secret material.
413
+ */
414
+ const IntegrationDiscordConfig = object({
415
+ botToken: string(),
416
+ applicationId: string().optional(),
417
+ allowedUserIds: array(string()).default([]),
418
+ bindRules: array(IntegrationBindRule).default([])
419
+ });
420
+ /**
421
+ * One platform integration, owned by exactly one agent. Also the element type of
422
+ * `RegisterOk.integrations[]` (the per-daemon reconcile set). Discriminated on
423
+ * `platform`: the daemon opens a Slack Socket Mode connection, a Telegram
424
+ * long-poll, or a Discord Gateway from whichever variant is delivered.
425
+ */
426
+ const IntegrationSpec = discriminatedUnion("platform", [
427
+ object({
428
+ integrationId: string().uuid(),
429
+ agentId: string().uuid(),
430
+ platform: literal("slack"),
431
+ slack: IntegrationSlackConfig
432
+ }),
433
+ object({
434
+ integrationId: string().uuid(),
435
+ agentId: string().uuid(),
436
+ platform: literal("telegram"),
437
+ telegram: IntegrationTelegramConfig
438
+ }),
439
+ object({
440
+ integrationId: string().uuid(),
441
+ agentId: string().uuid(),
442
+ platform: literal("discord"),
443
+ discord: IntegrationDiscordConfig
444
+ })
445
+ ]);
446
+ /** C→D EVT — install/update an integration on the owning agent's daemon. */
447
+ const IntegrationUpsert = IntegrationSpec;
448
+ /** C→D EVT — remove an integration from the daemon. */
449
+ const IntegrationRemove = object({ integrationId: string().uuid() });
450
+ /** One channel the bot is currently a member of (metadata only — no messages). */
451
+ const IntegrationChannel = object({
452
+ id: string(),
453
+ name: string().optional(),
454
+ isPrivate: boolean().optional()
455
+ });
456
+ /**
457
+ * D→C EVT — the FULL set of channels the integration's bot is a member of
458
+ * (fire-and-forget, latest-wins). Emitted on socket start and whenever the bot
459
+ * is invited to / removed from a channel, so the console can offer per-channel
460
+ * trigger config. Channel names are control metadata, never message content.
461
+ */
462
+ const IntegrationChannels = object({
463
+ integrationId: string().uuid(),
464
+ channels: array(IntegrationChannel)
465
+ });
466
+ //#endregion
467
+ //#region ../protocol/dist/frames/register.js
468
+ /**
469
+ * Capability upload + the reconcile snapshot — protocol §3.3.
470
+ *
471
+ * `register/ok` is the authoritative source of truth: the daemon converges its
472
+ * local cache to it. CP wins all conflicts, so re-issuing the same snapshot is
473
+ * idempotent.
474
+ */
475
+ const RegisterReq = object({
476
+ host: string(),
477
+ capabilities: object({
478
+ platforms: array(Platform),
479
+ runtimes: array(string()),
480
+ acp: boolean(),
481
+ features: array(string()).default([])
482
+ }),
483
+ maxAgents: number().int(),
484
+ localState: object({
485
+ assignments: array(string()),
486
+ crons: array(string()),
487
+ leases: array(string())
488
+ })
489
+ });
490
+ /**
491
+ * One relay the daemon SHOULD hold an outbound WS to (shared-bot-relay.md §5).
492
+ * The roster is all-to-all by design: a webchat/webhook landing on ANY relay
493
+ * instance must find this daemon's connection without cross-instance forwarding.
494
+ * That only holds if `url` (the relay's registered `daemonUrl`) routes to that
495
+ * SPECIFIC instance — the daemon confirms the landing spot against
496
+ * `rd/hello/ok.relayId` and treats a mismatch as a deployment misroute.
497
+ */
498
+ const RelayRosterEntry = object({
499
+ relayId: string().uuid(),
500
+ url: string()
501
+ });
502
+ /**
503
+ * C→D EVT (`relay/roster`) — hot roster update (relay registered / swept).
504
+ * Carries the WHOLE desired set, same converge-don't-diff semantics as the
505
+ * `register/ok.relays` snapshot it refreshes.
506
+ */
507
+ const RelayRosterUpdate = object({ relays: array(RelayRosterEntry) });
508
+ const RegisterOk = object({
509
+ routingEpoch: number().int(),
510
+ assignments: array(RouteAssign),
511
+ agents: array(AgentSpec.extend({ agentId: string().uuid() })).default([]),
512
+ crons: array(CronUpsert),
513
+ integrations: array(IntegrationSpec).default([]),
514
+ leases: array(SecretsGrant),
515
+ relays: array(RelayRosterEntry).default([]),
516
+ drop: object({
517
+ assignments: array(string()),
518
+ crons: array(string())
519
+ })
520
+ });
521
+ //#endregion
522
+ //#region ../protocol/dist/frames/gitcred.js
523
+ /**
524
+ * Git credentials (github-app workspaces) — daemon-pulled, CP-minted.
525
+ *
526
+ * The CP holds the GitHub App private key and mints short-lived (1h,
527
+ * non-renewable) installation access tokens scoped to a single repository;
528
+ * the daemon pulls one on demand right before a remote git operation and
529
+ * holds it in memory only. Unlike `secrets/*` (lease + reference semantics,
530
+ * still unwired), the grant here carries the TOKEN MATERIAL itself — same
531
+ * plaintext-over-TLS-WS posture as `integration/upsert`, and the same
532
+ * discipline: **never log the payload**.
533
+ *
534
+ * The daemon may only name an agentId — the CP resolves agent → workspace →
535
+ * repo → installation itself, so a daemon can never pick a repo it wasn't
536
+ * assigned. Failures come back as correlated `error` REPs: `SCOPE_DENIED`
537
+ * (not a github-app workspace, or agent not placed on this daemon — stop
538
+ * asking), `LEASE_DENIED` (installation uninstalled/suspended or the repo
539
+ * left its grant set — recoverable only by an operator), `RATE_LIMITED`,
540
+ * `INTERNAL`.
541
+ */
542
+ const GitCredRequest = object({
543
+ agentId: string().uuid(),
544
+ reason: _enum([
545
+ "clone",
546
+ "fetch",
547
+ "pull",
548
+ "push",
549
+ "helper"
550
+ ]).optional()
551
+ });
552
+ const GitCredGrant = object({
553
+ username: literal("x-access-token"),
554
+ token: string(),
555
+ ttlSec: number().int(),
556
+ expiresAt: string().datetime(),
557
+ repoFullName: string(),
558
+ access: _enum(["read", "write"])
559
+ });
560
+ //#endregion
561
+ //#region ../protocol/dist/frames/session.js
562
+ /**
563
+ * Session read-back (C→D REQ → REP) — the console's on-demand pulls.
564
+ *
565
+ * Session bodies are daemon-local (body-locality, §1/§12). The CP may persist a
566
+ * metadata-only session-list snapshot from daemon `event/session` events, while
567
+ * transcript/tool bodies are pulled live from the owning daemon and proxied to
568
+ * the console only for display.
569
+ *
570
+ * - `session/list`: the daemon's local metadata projection, used for snapshots
571
+ * and direct read-back/debugging.
572
+ * - `session/history`: one cursor-paginated page of a session's transcript.
573
+ */
574
+ /**
575
+ * Per-session token accounting, metered by the daemon from the agent's ACP usage
576
+ * stream. Token counts are CUMULATIVE across the session's turns (summed from each
577
+ * turn's `PromptResponse.usage`); context/cost are the LATEST snapshot from the
578
+ * agent's `usage_update` stream. Every field is optional — a runtime that reports
579
+ * no usage yields an absent field, not a zero.
580
+ */
581
+ const SessionUsage = object({
582
+ totalTokens: number().int().optional(),
583
+ inputTokens: number().int().optional(),
584
+ outputTokens: number().int().optional(),
585
+ thoughtTokens: number().int().optional(),
586
+ cachedReadTokens: number().int().optional(),
587
+ cachedWriteTokens: number().int().optional(),
588
+ contextUsed: number().int().optional(),
589
+ contextSize: number().int().optional(),
590
+ costAmount: number().optional(),
591
+ costCurrency: string().optional()
592
+ });
593
+ /** One row in the session list (metadata + console metrics; NOT the transcript). */
594
+ const SessionListItem = object({
595
+ sessionId: string(),
596
+ sessionKey: SessionKey,
597
+ agentId: string().uuid(),
598
+ title: string().optional(),
599
+ status: string().optional(),
600
+ lastActivityAt: string().optional(),
601
+ usage: SessionUsage.optional(),
602
+ triggeredBy: string().optional(),
603
+ channelName: string().optional(),
604
+ triggeredByName: string().optional(),
605
+ threadUrl: string().optional()
606
+ });
607
+ /** C→D REQ: list the daemon's sessions (optionally just one agent's). */
608
+ const SessionListReq = object({ agentId: string().uuid().optional() });
609
+ /** D→C REP (corr = req id): the daemon's current sessions. */
610
+ const SessionListPage = object({ sessions: array(SessionListItem) });
611
+ object({
612
+ toolCallId: string(),
613
+ kind: string().optional(),
614
+ status: string().optional(),
615
+ rawInput: unknown().optional(),
616
+ rawOutput: unknown().optional(),
617
+ content: array(unknown()).optional(),
618
+ locations: array(object({
619
+ path: string(),
620
+ line: number().int().optional()
621
+ })).optional(),
622
+ truncated: boolean().optional()
623
+ });
624
+ /** One message in a session transcript page (a body — returned only for display). */
625
+ const SessionMessage = object({
626
+ seq: number().int(),
627
+ sender: string(),
628
+ senderName: string().optional(),
629
+ ts: string(),
630
+ kind: string(),
631
+ text: string(),
632
+ toolCallId: string().optional(),
633
+ toolStatus: string().optional(),
634
+ toolKind: string().optional(),
635
+ body: string().optional(),
636
+ bodyTruncated: boolean().optional(),
637
+ bodyBytes: number().int().optional()
638
+ });
639
+ /** C→D REQ: fetch one page of a session's history from the owning daemon. */
640
+ const SessionHistoryReq = object({
641
+ sessionId: string(),
642
+ cursor: string().optional(),
643
+ limit: number().int().positive().max(200).default(50)
644
+ });
645
+ /** D→C REP (corr = the req id): a page of messages + the cursor for the next page. */
646
+ const SessionHistoryPage = object({
647
+ sessionId: string(),
648
+ messages: array(SessionMessage),
649
+ nextCursor: string().optional()
650
+ });
651
+ /**
652
+ * C→D REQ: fetch the FULL (untruncated) ToolBody JSON for one tool call, chunked.
653
+ * The inline `SessionMessage.body` preview is capped at 32 KiB; when a body is
654
+ * bigger the console pages the whole thing back through this frame by offset.
655
+ */
656
+ const SessionToolBodyReq = object({
657
+ sessionId: string(),
658
+ toolCallId: string(),
659
+ offset: number().int().nonnegative().default(0)
660
+ });
661
+ /** D→C REP (corr = the req id): one frame-budgeted byte slice of the full ToolBody JSON. */
662
+ const SessionToolBodyChunk = object({
663
+ sessionId: string(),
664
+ toolCallId: string(),
665
+ data: string(),
666
+ totalBytes: number().int(),
667
+ nextOffset: number().int().optional()
668
+ });
669
+ //#endregion
670
+ //#region ../protocol/dist/frames/channel.js
671
+ /**
672
+ * Channel agent directory (D→C REQ → REP) — agent collaboration lookup.
673
+ *
674
+ * An agent asks (via a daemon-side tool) "who else is in this channel?". The
675
+ * daemon forwards it to the CP — the ONLY authority for the FULL channel roster,
676
+ * because agents in one channel may run on different daemons (each daemon knows
677
+ * only its own agents). The CP joins the channel's integrations to their agents
678
+ * and returns their public metadata (name / displayName / description / status).
679
+ * Metadata only — never message content (§1/§12).
680
+ *
681
+ * Direction is daemon→CP (like `auth` / `register`): the daemon sends the REQ and
682
+ * the CP replies with `channel/agents/ok` (corr = the req id).
683
+ */
684
+ /** One agent visible in a channel — the collaboration directory entry. */
685
+ const ChannelAgent = object({
686
+ agentId: string().uuid(),
687
+ name: string(),
688
+ displayName: string().optional(),
689
+ description: string().optional(),
690
+ status: _enum([
691
+ "active",
692
+ "inactive",
693
+ "paused"
694
+ ])
695
+ });
696
+ /** D→C REQ: list the agents in a channel (for the asking agent's collaboration tool). */
697
+ const ChannelAgentsReq = object({
698
+ platform: Platform,
699
+ channel: string()
700
+ });
701
+ /** C→D REP (corr = req id): every agent in the channel across all daemons. */
702
+ const ChannelAgentsOk = object({
703
+ platform: Platform,
704
+ channel: string(),
705
+ agents: array(ChannelAgent)
706
+ });
707
+ //#endregion
708
+ //#region ../protocol/dist/frames/workspace.js
709
+ /**
710
+ * Agent workspace file browsing (C→D REQ → REP) — the console's on-demand pulls.
711
+ *
712
+ * The CP stores NO workspace data — directory listings and file bytes are pulled
713
+ * live from the owning daemon and proxied to the console, never persisted
714
+ * (body-locality). Missing paths are DATA (`exists:false`), not errors; only
715
+ * path-containment violations (`BAD_PAYLOAD`) and unexpected fs failures
716
+ * (`INTERNAL`) come back as `error` frames.
717
+ *
718
+ * - `workspace/list`: one cursor-paginated page of a directory's entries.
719
+ * - `workspace/read`: one byte slice of a file. `limit` is only a ceiling — the
720
+ * daemon returns FEWER bytes when needed to keep the JSON-escaped REP under the
721
+ * 256 KiB frame cap (control bytes escape to `\uXXXX`, a 6× blowup), and always
722
+ * ends the slice on a UTF-8 character boundary. `nextOffset` is the authoritative
723
+ * byte offset to request next — callers must NOT recompute it from the decoded
724
+ * `content` (a split multi-byte char would make the byte count drift).
725
+ */
726
+ /** One directory entry in a workspace listing (name-only; not a full path). */
727
+ const WorkspaceEntry = object({
728
+ name: string(),
729
+ type: _enum([
730
+ "dir",
731
+ "file",
732
+ "symlink",
733
+ "other"
734
+ ]),
735
+ size: number().int().nonnegative().optional(),
736
+ mtime: string().optional()
737
+ });
738
+ /** C→D REQ: list one page of a directory in the agent's workspace. */
739
+ const WorkspaceListReq = object({
740
+ agentId: string().min(1),
741
+ path: string().default(""),
742
+ cursor: string().optional(),
743
+ limit: number().int().positive().max(500).default(200)
744
+ });
745
+ /** D→C REP (corr = the req id): a page of entries + the cursor for the next page. */
746
+ const WorkspaceListPage = object({
747
+ agentId: string(),
748
+ path: string(),
749
+ exists: boolean(),
750
+ entries: array(WorkspaceEntry),
751
+ nextCursor: string().optional()
752
+ });
753
+ /** C→D REQ: read one byte slice of a file in the agent's workspace. */
754
+ const WorkspaceReadReq = object({
755
+ agentId: string().min(1),
756
+ path: string().min(1),
757
+ offset: number().int().nonnegative().default(0),
758
+ limit: number().int().positive().max(65536).default(65536)
759
+ });
760
+ /** D→C REP (corr = the req id): the file slice (or `exists:false` / binary-detected). */
761
+ const WorkspaceReadContent = object({
762
+ agentId: string(),
763
+ path: string(),
764
+ exists: boolean(),
765
+ size: number().int().nonnegative().optional(),
766
+ mtime: string().optional(),
767
+ encoding: _enum(["utf8", "none"]).optional(),
768
+ content: string().optional(),
769
+ offset: number().int().nonnegative().optional(),
770
+ nextOffset: number().int().nonnegative().optional(),
771
+ truncated: boolean().optional()
772
+ });
773
+ /**
774
+ * Agent workspace git ops (C→D REQ → REP) — the console's on-demand controls for
775
+ * a git-repo workspace. Like list/read these run daemon-local; the CP proxies the
776
+ * outcome and stores nothing.
777
+ *
778
+ * These are DATA-oriented: a non-repo workspace (`isRepo:false`), a dirty tree,
779
+ * or a pull that can't fast-forward (offline, diverged, local edits) all come
780
+ * back as a normal REP — NOT an `error` frame. Only an unknown agent
781
+ * (`BAD_PAYLOAD`) or an unexpected git/fs failure (`INTERNAL`) is an error.
782
+ */
783
+ /** C→D REQ: report `git status` of the agent's workspace (is it clean?). */
784
+ const WorkspaceGitStatusReq = object({ agentId: string().min(1) });
785
+ /** One changed path in a `git status`. `index`/`workingDir` are git's per-file XY
786
+ * status chars (' ', 'M', 'A', 'D', 'R', 'C', 'U', '?', …). */
787
+ const WorkspaceGitFile = object({
788
+ path: string(),
789
+ index: string(),
790
+ workingDir: string()
791
+ });
792
+ /** The HEAD commit of the checkout. */
793
+ const WorkspaceGitCommit = object({
794
+ sha: string(),
795
+ shortSha: string(),
796
+ subject: string(),
797
+ committedAt: string()
798
+ });
799
+ /** D→C REP (corr = the req id): the working-tree status. */
800
+ const WorkspaceGitStatus = object({
801
+ agentId: string(),
802
+ isRepo: boolean(),
803
+ clean: boolean(),
804
+ branch: string().optional(),
805
+ tracking: string().optional(),
806
+ ahead: number().int().nonnegative().optional(),
807
+ behind: number().int().nonnegative().optional(),
808
+ files: array(WorkspaceGitFile).optional(),
809
+ truncated: boolean().optional(),
810
+ lastCommit: WorkspaceGitCommit.optional(),
811
+ lastFetchAt: string().optional()
812
+ });
813
+ /** C→D REQ: force a fast-forward-only `git pull` in the agent's workspace now. */
814
+ const WorkspaceGitPullReq = object({ agentId: string().min(1) });
815
+ /** D→C REP (corr = the req id): the pull outcome. A failed pull is DATA
816
+ * (`ok:false` + `detail`), not an error frame (see the section docblock). */
817
+ const WorkspaceGitPullResult = object({
818
+ agentId: string(),
819
+ isRepo: boolean(),
820
+ ok: boolean(),
821
+ detail: string().optional(),
822
+ changed: number().int().nonnegative().optional(),
823
+ insertions: number().int().nonnegative().optional(),
824
+ deletions: number().int().nonnegative().optional()
825
+ });
826
+ //#endregion
827
+ //#region ../protocol/dist/frames/memory.js
828
+ /**
829
+ * Agent memory directory (C→D REQ → REP) — the console's read/write of an agent's
830
+ * long-term memory, and the surface the daemon serves it from.
831
+ *
832
+ * Memory is a DIRECTORY at the agent's ROOT (`<agent-root>/memory/`), OUTSIDE the
833
+ * workspace — so it survives a workspace reset / re-clone and is never committed
834
+ * into a github-workspace repo. It follows the index-plus-topics shape: a lean
835
+ * `MEMORY.md` index (injected into the prompt every session, bounded) plus any
836
+ * number of `<topic>.md` files the agent reads on demand. The agent maintains it
837
+ * via MCP tools; these frames are the third channel — the CP reading/writing it
838
+ * for the console — all pointing at the same directory.
839
+ *
840
+ * The CP stores NO memory content — bytes are pulled live from the owning daemon
841
+ * and proxied to the console, never persisted (body-locality). A not-yet-created
842
+ * file/dir is DATA (`exists:false`), not an error; only an unknown agent or a
843
+ * path-containment violation (`BAD_PAYLOAD`) or an unexpected fs failure
844
+ * (`INTERNAL`) comes back as an `error` frame.
845
+ *
846
+ * - `memory/list`: the topic files in the memory dir (flat; the index + topics).
847
+ * - `memory/read`: one byte slice of a memory file (`path` relative to the memory
848
+ * dir; default `MEMORY.md`). `limit` is a ceiling — the daemon returns fewer
849
+ * bytes to keep the JSON-escaped REP under the 256 KiB frame cap and always ends
850
+ * on a UTF-8 boundary. `nextOffset` is authoritative — do NOT recompute it.
851
+ * - `memory/write`: replace the whole named memory file with `content`.
852
+ */
853
+ /** The default file name of the memory index (loaded into the prompt each session). */
854
+ const MEMORY_INDEX = "MEMORY.md";
855
+ /** One entry in a memory-dir listing (name-only; not a path). */
856
+ const MemoryEntry = object({
857
+ name: string(),
858
+ size: number().int().nonnegative(),
859
+ mtime: string()
860
+ });
861
+ /** C→D REQ: list the files in the agent's memory dir. */
862
+ const MemoryListReq = object({ agentId: string().min(1) });
863
+ /** D→C REP (corr = the req id): the memory dir's files (or `exists:false`). */
864
+ const MemoryListPage = object({
865
+ agentId: string(),
866
+ exists: boolean(),
867
+ entries: array(MemoryEntry)
868
+ });
869
+ /** C→D REQ: read one byte slice of a memory file (path relative to the memory dir). */
870
+ const MemoryReadReq = object({
871
+ agentId: string().min(1),
872
+ path: string().default(MEMORY_INDEX),
873
+ offset: number().int().nonnegative().default(0),
874
+ limit: number().int().positive().max(65536).default(65536)
875
+ });
876
+ /** D→C REP (corr = the req id): the memory file slice (or `exists:false`). Always utf8 text. */
877
+ const MemoryReadContent = object({
878
+ agentId: string(),
879
+ path: string(),
880
+ exists: boolean(),
881
+ size: number().int().nonnegative().optional(),
882
+ mtime: string().optional(),
883
+ content: string().optional(),
884
+ offset: number().int().nonnegative().optional(),
885
+ nextOffset: number().int().nonnegative().optional(),
886
+ truncated: boolean().optional()
887
+ });
888
+ /** C→D REQ: replace the whole named memory file with `content` (console edit). */
889
+ const MemoryWriteReq = object({
890
+ agentId: string().min(1),
891
+ path: string().default(MEMORY_INDEX),
892
+ content: string(),
893
+ ifMatchMtime: string().optional()
894
+ });
895
+ /** D→C REP (corr = the req id): the written state, so the console can refresh. */
896
+ const MemoryWriteOk = object({
897
+ agentId: string(),
898
+ path: string(),
899
+ size: number().int().nonnegative(),
900
+ mtime: string()
901
+ });
902
+ //#endregion
903
+ //#region ../protocol/dist/frames/telemetry.js
904
+ /**
905
+ * Telemetry & facts (D→C) — protocol §7.
906
+ *
907
+ * The dashboard's real-time source. `event/session` is the converged milestone
908
+ * feed (NOT the message stream); `facts/daemon-runtimes` is the observed runtime
909
+ * capability feed; `heartbeat` drives the watchdog.
910
+ */
911
+ const Heartbeat = object({
912
+ load: object({
913
+ cpu: number(),
914
+ mem: number(),
915
+ agents: number().int()
916
+ }),
917
+ health: _enum(["ok", "degraded"]),
918
+ activeSessions: number().int(),
919
+ degradedScopes: array(string()).default([])
920
+ });
921
+ /** Converged session lifecycle milestone — protocol §7.2. NOT the message stream. */
922
+ const EventSession = object({
923
+ sessionId: string(),
924
+ agentId: string().uuid(),
925
+ launchId: string().uuid().optional(),
926
+ phase: _enum([
927
+ "start",
928
+ "plan",
929
+ "problem",
930
+ "end"
931
+ ]),
932
+ platform: Platform.optional(),
933
+ channel: string().optional(),
934
+ thread: string().optional(),
935
+ link: string().optional(),
936
+ summary: string().optional(),
937
+ title: string().optional(),
938
+ status: string().optional(),
939
+ lastActivityAt: string().datetime().optional(),
940
+ triggeredBy: string().optional(),
941
+ channelName: string().optional(),
942
+ triggeredByName: string().optional(),
943
+ threadUrl: string().optional(),
944
+ ts: string().datetime()
945
+ });
946
+ /**
947
+ * Per-session token-usage report — D→C EVT. The daemon meters usage from the
948
+ * agent's ACP stream and reports the session's CUMULATIVE snapshot (latest-wins)
949
+ * so the CP can persist it for the console's historical usage aggregates. This is
950
+ * dashboard metadata (token counts + cost), never the message stream. Idempotent
951
+ * by `sessionId`: re-sending the same snapshot is a no-op upsert on the CP.
952
+ */
953
+ const UsageReport = object({
954
+ sessionId: string(),
955
+ agentId: string().uuid(),
956
+ platform: string().optional(),
957
+ channel: string().optional(),
958
+ lastActivityAt: string(),
959
+ usage: SessionUsage
960
+ });
961
+ /**
962
+ * Observed capabilities of an installed runtime — protocol §7.3. The element
963
+ * type of the `facts/daemon-runtimes` snapshot. As a standalone per-runtime
964
+ * frame (`facts/runtime-profile`, upsert-only) it is DEPRECATED: current
965
+ * daemons report the full snapshot instead; the CP keeps accepting the old
966
+ * frame from older daemons.
967
+ */
968
+ /**
969
+ * MCP transports a runtime accepts on `session/new` `mcpServers` (from the ACP
970
+ * `initialize` response's `agentCapabilities.mcpCapabilities`). Stdio is the ACP
971
+ * baseline every agent must accept, so only the optional transports are listed.
972
+ */
973
+ const McpTransportCapabilities = object({
974
+ http: boolean(),
975
+ sse: boolean()
976
+ });
977
+ const FactsRuntimeProfile = object({
978
+ runtime: string(),
979
+ version: string(),
980
+ models: array(string()),
981
+ contextWindow: number().int().optional(),
982
+ acpSupport: _enum([
983
+ "full",
984
+ "partial",
985
+ "none"
986
+ ]),
987
+ acpProtocolVersion: number().int().optional(),
988
+ toolCalling: boolean(),
989
+ mcpCapabilities: McpTransportCapabilities.optional()
990
+ });
991
+ /**
992
+ * Probe result for one daemon-configured MCP server (daemon config `mcpServers`).
993
+ * The daemon connects to the server (MCP initialize + `tools/list`) so the
994
+ * console can show what each server offers before the user enables it on an
995
+ * agent. Metadata only — definitions (commands, URLs, headers) stay daemon-local.
996
+ */
997
+ const FactsMcpServer = object({
998
+ name: string(),
999
+ transport: _enum([
1000
+ "stdio",
1001
+ "http",
1002
+ "sse"
1003
+ ]),
1004
+ /** True iff the MCP handshake succeeded. */
1005
+ ok: boolean(),
1006
+ tools: array(string()).default([]),
1007
+ version: string().optional(),
1008
+ error: string().optional()
1009
+ });
1010
+ /**
1011
+ * Full snapshot of the daemon's installed runtimes — D→C EVT (protocol §7.3a),
1012
+ * emitted on each register and again once the background probe sweep completes
1013
+ * (with the learned `models[]`). REPLACE semantics: the CP reconciles its
1014
+ * stored runtime list for the daemon to exactly `runtimes[]`, pruning runtimes
1015
+ * that are no longer installed. Idempotent (latest-wins). Supersedes the
1016
+ * per-runtime `facts/runtime-profile` upsert.
1017
+ */
1018
+ const DaemonRuntimes = object({
1019
+ runtimes: array(FactsRuntimeProfile),
1020
+ mcpServers: array(FactsMcpServer).default([])
1021
+ });
1022
+ /** Non-secret config update — C→D EVT, protocol §8.3 / frame #26. */
1023
+ const ConfigPush = object({ keys: record(string(), unknown()) });
1024
+ /** Fleet: drain+exit, supervisor restarts — C→D REQ, protocol §8.3 / frame #27. */
1025
+ const DaemonRestart = object({
1026
+ reason: string(),
1027
+ drainFirst: boolean().default(true)
1028
+ });
1029
+ /** Fleet: drain+exit for version bump — C→D REQ, protocol §8.3 / frame #28. */
1030
+ const DaemonUpgrade = object({
1031
+ targetVersion: string(),
1032
+ drainFirst: boolean().default(true)
1033
+ });
1034
+ /** Reply to a fleet restart/upgrade REQ (protocol §8.3). */
1035
+ const DaemonControlAck = object({
1036
+ accepted: boolean(),
1037
+ willDrainUntil: string().datetime().optional()
1038
+ });
1039
+ /**
1040
+ * Generic acknowledgement for REQs whose reply is just `ack` in the frame index
1041
+ * (cron/upsert, cron/remove, agent/stop). Kept permissive but typed.
1042
+ */
1043
+ const Ack = object({
1044
+ ok: boolean(),
1045
+ reason: string().optional()
1046
+ });
1047
+ const ErrorFrame = object({
1048
+ code: _enum([
1049
+ "UNKNOWN_FRAME",
1050
+ "FRAME_TOO_LARGE",
1051
+ "PROTOCOL_STATE",
1052
+ "BAD_PAYLOAD",
1053
+ "AUTH_FAILED",
1054
+ "ATTESTATION_INVALID",
1055
+ "STALE_EPOCH",
1056
+ "STALE_LAUNCH",
1057
+ "NO_SESSION",
1058
+ "SCOPE_DENIED",
1059
+ "LEASE_EXPIRED",
1060
+ "LEASE_DENIED",
1061
+ "RATE_LIMITED",
1062
+ "CONFLICT",
1063
+ "INTERNAL"
1064
+ ]),
1065
+ message: string(),
1066
+ retryable: boolean(),
1067
+ details: record(string(), unknown()).optional()
1068
+ });
1069
+ object({
1070
+ accepted: boolean(),
1071
+ turnId: string().uuid(),
1072
+ reason: _enum([
1073
+ "queued",
1074
+ "no_agent",
1075
+ "busy",
1076
+ "paused",
1077
+ "draining"
1078
+ ]).optional()
1079
+ });
1080
+ const WebchatEvent = discriminatedUnion("kind", [
1081
+ object({
1082
+ kind: literal("message"),
1083
+ text: string()
1084
+ }),
1085
+ object({
1086
+ kind: literal("thinking"),
1087
+ text: string()
1088
+ }),
1089
+ object({
1090
+ kind: literal("tool_call"),
1091
+ toolCallId: string(),
1092
+ title: string(),
1093
+ status: string()
1094
+ }),
1095
+ object({
1096
+ kind: literal("tool_update"),
1097
+ toolCallId: string(),
1098
+ status: string()
1099
+ }),
1100
+ object({
1101
+ kind: literal("session_info"),
1102
+ title: string()
1103
+ })
1104
+ ]);
1105
+ const WebchatStatus = object({
1106
+ model: string().optional(),
1107
+ effort: string().optional(),
1108
+ permissionMode: string().optional(),
1109
+ fastMode: boolean().optional(),
1110
+ contextUsed: number().int().optional(),
1111
+ contextSize: number().int().optional(),
1112
+ totalTokens: number().int().optional(),
1113
+ costAmount: number().optional(),
1114
+ costCurrency: string().optional(),
1115
+ models: array(string()).optional(),
1116
+ efforts: array(string()).optional(),
1117
+ permissionModes: array(string()).optional(),
1118
+ fastModeAvailable: boolean().optional(),
1119
+ sessionId: string().optional()
1120
+ });
1121
+ const WebchatOutput = object({
1122
+ conversationId: string().uuid(),
1123
+ turnId: string().uuid(),
1124
+ index: number().int(),
1125
+ event: WebchatEvent.optional(),
1126
+ status: WebchatStatus.optional()
1127
+ }).refine((o) => o.event !== void 0 || o.status !== void 0, { message: "WebchatOutput must carry event and/or status" });
1128
+ const WebchatDone = object({
1129
+ conversationId: string().uuid(),
1130
+ turnId: string().uuid(),
1131
+ stopReason: string().optional(),
1132
+ usage: object({
1133
+ used: number().int().optional(),
1134
+ cost: number().optional()
1135
+ }).optional(),
1136
+ error: string().optional()
1137
+ });
1138
+ //#endregion
1139
+ //#region ../protocol/dist/frame.js
1140
+ /**
1141
+ * The single source of truth for the wire: `type` string → payload zod schema.
1142
+ *
1143
+ * Mirrors the frame index in daemon-cp-ws-protocol.md §10 (the 29 numbered
1144
+ * frames) plus the correlated REP types named in the "Reply" column
1145
+ * (`route/assign/ack`, `drain/done`, and the generic `ack` replies) that also
1146
+ * travel on the wire and must be decodable.
1147
+ *
1148
+ * `ws/codec.ts` validates every inbound `payload` against `FRAME_SCHEMAS[type]`;
1149
+ * an unknown `type` → `ErrorFrame{code:"UNKNOWN_FRAME"}` (a REP, not a close).
1150
+ */
1151
+ const FRAME_SCHEMAS = {
1152
+ auth: AuthReq,
1153
+ "auth/ok": AuthOk,
1154
+ register: RegisterReq,
1155
+ "register/ok": RegisterOk,
1156
+ "relay/roster": RelayRosterUpdate,
1157
+ heartbeat: Heartbeat,
1158
+ "agent/launch": AgentLaunch,
1159
+ "agent/launched": AgentLaunched,
1160
+ "agent/stop": AgentStop,
1161
+ "agent/upsert": AgentUpsert,
1162
+ "agent/remove": AgentRemove,
1163
+ "agent/activity": AgentActivity,
1164
+ "agent/scope-denied": AgentScopeDenied,
1165
+ "route/assign": RouteAssign,
1166
+ "route/assign/ack": RouteAssignAck,
1167
+ "route/update": RouteUpdate,
1168
+ "daemon/drain": Drain,
1169
+ "drain/progress": DrainProgress,
1170
+ "drain/done": DrainDone,
1171
+ "cron/upsert": CronUpsert,
1172
+ "cron/remove": CronRemove,
1173
+ "cron/report": CronReport,
1174
+ "cron/run": CronRunNow,
1175
+ "integration/upsert": IntegrationUpsert,
1176
+ "integration/remove": IntegrationRemove,
1177
+ "integration/channels": IntegrationChannels,
1178
+ "gitcred/request": GitCredRequest,
1179
+ "gitcred/grant": GitCredGrant,
1180
+ "secrets/request": SecretsRequest,
1181
+ "secrets/grant": SecretsGrant,
1182
+ "secrets/renew": SecretsRenew,
1183
+ "secrets/revoke": SecretsRevoke,
1184
+ "scope-attestation": ScopeAttestation,
1185
+ "event/session": EventSession,
1186
+ "usage/report": UsageReport,
1187
+ "facts/runtime-profile": FactsRuntimeProfile,
1188
+ "facts/daemon-runtimes": DaemonRuntimes,
1189
+ "session/list": SessionListReq,
1190
+ "session/list/page": SessionListPage,
1191
+ "session/history": SessionHistoryReq,
1192
+ "session/history/page": SessionHistoryPage,
1193
+ "session/tool-body": SessionToolBodyReq,
1194
+ "session/tool-body/chunk": SessionToolBodyChunk,
1195
+ "channel/agents": ChannelAgentsReq,
1196
+ "channel/agents/ok": ChannelAgentsOk,
1197
+ "workspace/list": WorkspaceListReq,
1198
+ "workspace/list/page": WorkspaceListPage,
1199
+ "workspace/read": WorkspaceReadReq,
1200
+ "workspace/read/content": WorkspaceReadContent,
1201
+ "workspace/gitstatus": WorkspaceGitStatusReq,
1202
+ "workspace/gitstatus/result": WorkspaceGitStatus,
1203
+ "workspace/gitpull": WorkspaceGitPullReq,
1204
+ "workspace/gitpull/result": WorkspaceGitPullResult,
1205
+ "memory/list": MemoryListReq,
1206
+ "memory/list/page": MemoryListPage,
1207
+ "memory/read": MemoryReadReq,
1208
+ "memory/read/content": MemoryReadContent,
1209
+ "memory/write": MemoryWriteReq,
1210
+ "memory/write/ok": MemoryWriteOk,
1211
+ "config/push": ConfigPush,
1212
+ "daemon/restart": DaemonRestart,
1213
+ "daemon/upgrade": DaemonUpgrade,
1214
+ "daemon/control/ack": DaemonControlAck,
1215
+ ack: Ack,
1216
+ error: ErrorFrame
1217
+ };
1218
+ Object.keys(FRAME_SCHEMAS);
1219
+ /**
1220
+ * Builds the envelope schema for one frame `type` with a `type` literal and the
1221
+ * typed payload, so the discriminated union infers `payload` precisely.
1222
+ *
1223
+ * Kept as a local generic (payload pinned to `FRAME_SCHEMAS[T]`) so a
1224
+ * mispaired member — `frame('auth', FRAME_SCHEMAS['register'])` — is a compile
1225
+ * error; a bare `frameSchema` alias would accept it. The body is deliberately
1226
+ * inlined rather than delegated: `return frameSchema(type, payload)` under
1227
+ * THIS generic signature collapses every union member's `payload` to `unknown`
1228
+ * (113 × TS18046 under TS 6.0.3 / zod 4.4.3 — reproducible by making exactly
1229
+ * that substitution), while the small relay unions call `frameSchema` with
1230
+ * concrete schemas and infer fine.
1231
+ */
1232
+ function frame(type, payload) {
1233
+ return object({
1234
+ v: literal(1),
1235
+ id: string().uuid(),
1236
+ ts: string().datetime(),
1237
+ type: literal(type),
1238
+ corr: string().uuid().optional(),
1239
+ payload
1240
+ });
1241
+ }
1242
+ discriminatedUnion("type", [
1243
+ frame("auth", FRAME_SCHEMAS["auth"]),
1244
+ frame("auth/ok", FRAME_SCHEMAS["auth/ok"]),
1245
+ frame("register", FRAME_SCHEMAS["register"]),
1246
+ frame("register/ok", FRAME_SCHEMAS["register/ok"]),
1247
+ frame("relay/roster", FRAME_SCHEMAS["relay/roster"]),
1248
+ frame("heartbeat", FRAME_SCHEMAS["heartbeat"]),
1249
+ frame("agent/launch", FRAME_SCHEMAS["agent/launch"]),
1250
+ frame("agent/launched", FRAME_SCHEMAS["agent/launched"]),
1251
+ frame("agent/stop", FRAME_SCHEMAS["agent/stop"]),
1252
+ frame("agent/upsert", FRAME_SCHEMAS["agent/upsert"]),
1253
+ frame("agent/remove", FRAME_SCHEMAS["agent/remove"]),
1254
+ frame("agent/activity", FRAME_SCHEMAS["agent/activity"]),
1255
+ frame("agent/scope-denied", FRAME_SCHEMAS["agent/scope-denied"]),
1256
+ frame("route/assign", FRAME_SCHEMAS["route/assign"]),
1257
+ frame("route/assign/ack", FRAME_SCHEMAS["route/assign/ack"]),
1258
+ frame("route/update", FRAME_SCHEMAS["route/update"]),
1259
+ frame("daemon/drain", FRAME_SCHEMAS["daemon/drain"]),
1260
+ frame("drain/progress", FRAME_SCHEMAS["drain/progress"]),
1261
+ frame("drain/done", FRAME_SCHEMAS["drain/done"]),
1262
+ frame("cron/upsert", FRAME_SCHEMAS["cron/upsert"]),
1263
+ frame("cron/remove", FRAME_SCHEMAS["cron/remove"]),
1264
+ frame("cron/report", FRAME_SCHEMAS["cron/report"]),
1265
+ frame("cron/run", FRAME_SCHEMAS["cron/run"]),
1266
+ frame("integration/upsert", FRAME_SCHEMAS["integration/upsert"]),
1267
+ frame("integration/remove", FRAME_SCHEMAS["integration/remove"]),
1268
+ frame("integration/channels", FRAME_SCHEMAS["integration/channels"]),
1269
+ frame("gitcred/request", FRAME_SCHEMAS["gitcred/request"]),
1270
+ frame("gitcred/grant", FRAME_SCHEMAS["gitcred/grant"]),
1271
+ frame("secrets/request", FRAME_SCHEMAS["secrets/request"]),
1272
+ frame("secrets/grant", FRAME_SCHEMAS["secrets/grant"]),
1273
+ frame("secrets/renew", FRAME_SCHEMAS["secrets/renew"]),
1274
+ frame("secrets/revoke", FRAME_SCHEMAS["secrets/revoke"]),
1275
+ frame("scope-attestation", FRAME_SCHEMAS["scope-attestation"]),
1276
+ frame("event/session", FRAME_SCHEMAS["event/session"]),
1277
+ frame("usage/report", FRAME_SCHEMAS["usage/report"]),
1278
+ frame("facts/runtime-profile", FRAME_SCHEMAS["facts/runtime-profile"]),
1279
+ frame("facts/daemon-runtimes", FRAME_SCHEMAS["facts/daemon-runtimes"]),
1280
+ frame("session/list", FRAME_SCHEMAS["session/list"]),
1281
+ frame("session/list/page", FRAME_SCHEMAS["session/list/page"]),
1282
+ frame("session/history", FRAME_SCHEMAS["session/history"]),
1283
+ frame("session/history/page", FRAME_SCHEMAS["session/history/page"]),
1284
+ frame("session/tool-body", FRAME_SCHEMAS["session/tool-body"]),
1285
+ frame("session/tool-body/chunk", FRAME_SCHEMAS["session/tool-body/chunk"]),
1286
+ frame("channel/agents", FRAME_SCHEMAS["channel/agents"]),
1287
+ frame("channel/agents/ok", FRAME_SCHEMAS["channel/agents/ok"]),
1288
+ frame("workspace/list", FRAME_SCHEMAS["workspace/list"]),
1289
+ frame("workspace/list/page", FRAME_SCHEMAS["workspace/list/page"]),
1290
+ frame("workspace/read", FRAME_SCHEMAS["workspace/read"]),
1291
+ frame("workspace/read/content", FRAME_SCHEMAS["workspace/read/content"]),
1292
+ frame("workspace/gitstatus", FRAME_SCHEMAS["workspace/gitstatus"]),
1293
+ frame("workspace/gitstatus/result", FRAME_SCHEMAS["workspace/gitstatus/result"]),
1294
+ frame("workspace/gitpull", FRAME_SCHEMAS["workspace/gitpull"]),
1295
+ frame("workspace/gitpull/result", FRAME_SCHEMAS["workspace/gitpull/result"]),
1296
+ frame("memory/list", FRAME_SCHEMAS["memory/list"]),
1297
+ frame("memory/list/page", FRAME_SCHEMAS["memory/list/page"]),
1298
+ frame("memory/read", FRAME_SCHEMAS["memory/read"]),
1299
+ frame("memory/read/content", FRAME_SCHEMAS["memory/read/content"]),
1300
+ frame("memory/write", FRAME_SCHEMAS["memory/write"]),
1301
+ frame("memory/write/ok", FRAME_SCHEMAS["memory/write/ok"]),
1302
+ frame("config/push", FRAME_SCHEMAS["config/push"]),
1303
+ frame("daemon/restart", FRAME_SCHEMAS["daemon/restart"]),
1304
+ frame("daemon/upgrade", FRAME_SCHEMAS["daemon/upgrade"]),
1305
+ frame("daemon/control/ack", FRAME_SCHEMAS["daemon/control/ack"]),
1306
+ frame("ack", FRAME_SCHEMAS["ack"]),
1307
+ frame("error", FRAME_SCHEMAS["error"])
1308
+ ]);
1309
+ //#endregion
1310
+ //#region ../protocol/dist/wire.js
1311
+ /**
1312
+ * Generic envelope codec — the schema-map-agnostic core shared by every wire.
1313
+ *
1314
+ * The daemon↔CP wire (`codec.ts` over `FRAME_SCHEMAS`) and the two relay wires
1315
+ * (`frames/relay-cp.ts` / `frames/relay-daemon.ts` over their OWN maps) all
1316
+ * decode the same envelope shape but accept disjoint frame families — a relay
1317
+ * socket must answer a daemon↔CP frame with `UNKNOWN_FRAME`, and vice versa
1318
+ * (shared-bot-relay.md §8 "独立帧 union,不混入 daemon 协议").
1319
+ */
1320
+ /** Soft cap per frame — 256 KiB (protocol §1). Over this → FRAME_TOO_LARGE. */
1321
+ const MAX_FRAME_BYTES = 256 * 1024;
1322
+ const textEncoder = new TextEncoder();
1323
+ function byteLength(text) {
1324
+ return textEncoder.encode(text).length;
1325
+ }
1326
+ function extractControlExt(json) {
1327
+ if (typeof json !== "object" || json === null) return void 0;
1328
+ const o = json;
1329
+ const ext = {};
1330
+ if (typeof o.epoch === "number") ext.epoch = o.epoch;
1331
+ if (typeof o.agentId === "string") ext.agentId = o.agentId;
1332
+ if (typeof o.launchId === "string") ext.launchId = o.launchId;
1333
+ return Object.keys(ext).length > 0 ? ext : void 0;
1334
+ }
1335
+ /**
1336
+ * Decode one wire frame against `schemas` (the wire's `type` → payload map).
1337
+ * Two-step parse: envelope first, then the per-type payload schema — an unknown
1338
+ * `type` is a typed `UNKNOWN_FRAME` failure (a REP, never a hard close).
1339
+ */
1340
+ function decodeEnvelopeWith(schemas, text) {
1341
+ if (byteLength(text) > 262144) return {
1342
+ ok: false,
1343
+ id: NIL_UUID,
1344
+ msg: "FRAME_TOO_LARGE"
1345
+ };
1346
+ let json;
1347
+ try {
1348
+ json = JSON.parse(text);
1349
+ } catch {
1350
+ return {
1351
+ ok: false,
1352
+ id: NIL_UUID,
1353
+ msg: "invalid json"
1354
+ };
1355
+ }
1356
+ const env = Envelope.safeParse(json);
1357
+ if (!env.success) return {
1358
+ ok: false,
1359
+ id: typeof json === "object" && json !== null && typeof json.id === "string" ? json.id : NIL_UUID,
1360
+ msg: env.error.message
1361
+ };
1362
+ const schema = schemas[env.data.type];
1363
+ if (!schema) return {
1364
+ ok: false,
1365
+ id: env.data.id,
1366
+ msg: "UNKNOWN_FRAME"
1367
+ };
1368
+ const payload = schema.safeParse(env.data.payload);
1369
+ if (!payload.success) return {
1370
+ ok: false,
1371
+ id: env.data.id,
1372
+ msg: payload.error.message
1373
+ };
1374
+ const ext = extractControlExt(json);
1375
+ return {
1376
+ ok: true,
1377
+ frame: {
1378
+ ...env.data,
1379
+ payload: payload.data
1380
+ },
1381
+ ...ext ? { ext } : {}
1382
+ };
1383
+ }
1384
+ /**
1385
+ * Wrap a payload in the wire envelope. Untyped core — each wire exports a typed
1386
+ * wrapper (`buildEnvelope` / `buildRelayCpFrame` / `buildRelayDaemonFrame`) that
1387
+ * pins `type` to its own union and casts the result.
1388
+ */
1389
+ function buildEnvelopeRaw(type, payload, opts = {}) {
1390
+ return {
1391
+ v: 1,
1392
+ id: opts.id ?? randomUUID(),
1393
+ ts: opts.ts ?? (/* @__PURE__ */ new Date()).toISOString(),
1394
+ type,
1395
+ payload,
1396
+ ...opts.corr ? { corr: opts.corr } : {},
1397
+ ...opts.ext ?? {}
1398
+ };
1399
+ }
1400
+ //#endregion
1401
+ //#region ../protocol/dist/codec.js
1402
+ function decodeEnvelope(text) {
1403
+ return decodeEnvelopeWith(FRAME_SCHEMAS, text);
1404
+ }
1405
+ function buildEnvelope(type, payload, opts = {}) {
1406
+ return buildEnvelopeRaw(type, payload, opts);
1407
+ }
1408
+ function encode(frame) {
1409
+ return JSON.stringify(frame);
1410
+ }
1411
+ //#endregion
1412
+ //#region ../protocol/dist/frames/relay-daemon.js
1413
+ /**
1414
+ * relay↔daemon frames (`rd/*`) — shared-bot-relay.md §7.2.
1415
+ *
1416
+ * The daemon dials OUT to every relay in its roster (one WS per (daemon, relay)
1417
+ * pair, multiplexing every source). This wire CARRIES MESSAGE CONTENT — it is
1418
+ * the ingress data plane — so nothing on it is ever logged with its payload.
1419
+ *
1420
+ * Direction per frame:
1421
+ * - `rd/hello` (+ `rd/hello/ok`) D→R REQ / R→D REP — authenticate the dial-in
1422
+ * - `rd/msg` R→D REQ — one inbound item, already routed
1423
+ * - `rd/ack` D→R REP — receipt (+ webchat turn verdict)
1424
+ * - `rd/chat` D→R EVT — webchat output stream back to the
1425
+ * browser (webchat is the one BIDIRECTIONAL
1426
+ * source: its far end hangs off the relay)
1427
+ *
1428
+ * Milestone A carries the webchat slice only; `im` (shared bot) and `hook`
1429
+ * (webhook) members join the `rd/msg` union with milestone B.
1430
+ */
1431
+ /** Subprotocol negotiated on the relay↔daemon socket. */
1432
+ const RELAY_DAEMON_SUBPROTOCOL = "agentconnect.rd.v1";
1433
+ /** The canonical path the relay mounts its daemon-facing `rd/*` server at, and
1434
+ * the daemon appends to a roster `daemonUrl` origin when dialing (mirrors the
1435
+ * daemon↔CP `/daemon/ws` convention). A roster url already ending in it is used
1436
+ * verbatim. */
1437
+ const RELAY_DAEMON_WS_PATH = "/rd/ws";
1438
+ const RdHello = object({
1439
+ apiKey: string().min(1),
1440
+ daemonId: string().uuid()
1441
+ });
1442
+ const RdHelloOk = object({ relayId: string().uuid() });
1443
+ const RelayWebchatOp = discriminatedUnion("op", [
1444
+ object({
1445
+ op: literal("turn"),
1446
+ text: string(),
1447
+ user: string().optional()
1448
+ }),
1449
+ object({
1450
+ op: literal("set_model"),
1451
+ model: string()
1452
+ }),
1453
+ object({
1454
+ op: literal("set_effort"),
1455
+ effort: string()
1456
+ }),
1457
+ object({
1458
+ op: literal("set_permission_mode"),
1459
+ permissionMode: string()
1460
+ }),
1461
+ object({
1462
+ op: literal("set_fast"),
1463
+ fastMode: boolean()
1464
+ }),
1465
+ object({ op: literal("cancel") }),
1466
+ object({ op: literal("close") })
1467
+ ]);
1468
+ const RdMsg = discriminatedUnion("source", [object({
1469
+ source: literal("webchat"),
1470
+ agentId: string().uuid(),
1471
+ sessionKey: string().min(1),
1472
+ msgId: string().min(1),
1473
+ chatId: string().uuid(),
1474
+ payload: RelayWebchatOp
1475
+ })]);
1476
+ const RdAck = object({
1477
+ msgId: string(),
1478
+ accepted: boolean(),
1479
+ turnId: string().uuid().optional(),
1480
+ reason: string().optional()
1481
+ });
1482
+ const RdChatEvent = discriminatedUnion("kind", [object({
1483
+ kind: literal("output"),
1484
+ output: WebchatOutput
1485
+ }), object({
1486
+ kind: literal("done"),
1487
+ done: WebchatDone
1488
+ })]);
1489
+ /** `type` string → payload schema for the relay↔daemon wire. */
1490
+ const RELAY_DAEMON_SCHEMAS = {
1491
+ "rd/hello": RdHello,
1492
+ "rd/hello/ok": RdHelloOk,
1493
+ "rd/msg": RdMsg,
1494
+ "rd/ack": RdAck,
1495
+ "rd/chat": object({
1496
+ chatId: string().uuid(),
1497
+ seq: number().int(),
1498
+ event: RdChatEvent
1499
+ }),
1500
+ error: ErrorFrame
1501
+ };
1502
+ Object.keys(RELAY_DAEMON_SCHEMAS);
1503
+ discriminatedUnion("type", [
1504
+ frameSchema("rd/hello", RELAY_DAEMON_SCHEMAS["rd/hello"]),
1505
+ frameSchema("rd/hello/ok", RELAY_DAEMON_SCHEMAS["rd/hello/ok"]),
1506
+ frameSchema("rd/msg", RELAY_DAEMON_SCHEMAS["rd/msg"]),
1507
+ frameSchema("rd/ack", RELAY_DAEMON_SCHEMAS["rd/ack"]),
1508
+ frameSchema("rd/chat", RELAY_DAEMON_SCHEMAS["rd/chat"]),
1509
+ frameSchema("error", RELAY_DAEMON_SCHEMAS["error"])
1510
+ ]);
1511
+ /** Decode one relay↔daemon wire frame (envelope + typed payload). */
1512
+ function decodeRelayDaemonFrame(text) {
1513
+ return decodeEnvelopeWith(RELAY_DAEMON_SCHEMAS, text);
1514
+ }
1515
+ /** Build a relay↔daemon frame with a compile-time-typed payload. */
1516
+ function buildRelayDaemonFrame(type, payload, opts = {}) {
1517
+ return buildEnvelopeRaw(type, payload, opts);
1518
+ }
1519
+ //#endregion
1520
+ //#region ../protocol/dist/git-url.js
1521
+ /**
1522
+ * Git repo address helpers, shared by the CP (normalize on write) and the
1523
+ * daemon (defensive normalize before clone).
1524
+ *
1525
+ * STORAGE INVARIANT: `workspace.gitRepo` is persisted as a FULL cloneable git
1526
+ * address (e.g. `https://github.com/acme/infra`), never the `acme/infra`
1527
+ * shorthand a user may type. UIs shorten it back to `org/repo` for display
1528
+ * with `gitRepoLabel`.
1529
+ */
1530
+ /** Scheme-full address: https://, ssh://, git://, file://, … */
1531
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
1532
+ /** scp-like ssh address: git@github.com:acme/infra(.git) */
1533
+ const SCP_RE = /^[\w.-]+@[\w.-]+:(.+)$/;
1534
+ /**
1535
+ * Normalize a user-supplied repo reference into a full cloneable git address.
1536
+ * Idempotent — full addresses pass through unchanged.
1537
+ *
1538
+ * - `https://…` / `ssh://…` / `git://…` / `git@host:…` → as-is
1539
+ * - `github.com/acme/infra` (host-prefixed shorthand) → `https://github.com/acme/infra`
1540
+ * - `acme/infra` (bare org/repo) → `https://github.com/acme/infra`
1541
+ * - anything else (e.g. a local path) → as-is
1542
+ */
1543
+ function normalizeGitUrl(input) {
1544
+ const s = input.trim().replace(/\/+$/, "");
1545
+ if (!s || SCHEME_RE.test(s) || SCP_RE.test(s)) return s;
1546
+ const segments = s.split("/");
1547
+ if (segments.length >= 3 && segments[0].includes(".")) return `https://${s}`;
1548
+ if (segments.length === 2 && segments.every((p) => p.length > 0)) return `https://github.com/${s}`;
1549
+ return s;
1550
+ }
1551
+ //#endregion
1552
+ //#region ../protocol/dist/index.js
1553
+ /**
1554
+ * Narrowing guard factory: `isFrame("auth")(frame)` narrows a decoded
1555
+ * `AnyFrame` to the member whose `type` matches.
1556
+ */
1557
+ function isFrame(type) {
1558
+ return (frame) => frame.type === type;
1559
+ }
1560
+ //#endregion
1561
+ //#region src/config/config-schema.ts
1562
+ /** The `{name, value}[]` shape shared by runtime env, MCP env, and MCP headers. */
1563
+ const NameValueList = array(object({
1564
+ name: string(),
1565
+ value: string()
1566
+ })).default([]);
1567
+ const RuntimeDefSchema = object({
1568
+ command: string(),
1569
+ args: array(string()).default([]),
1570
+ env: NameValueList
1571
+ });
1572
+ const McpServerDefSchema = object({
1573
+ transport: _enum([
1574
+ "stdio",
1575
+ "http",
1576
+ "sse"
1577
+ ]).default("stdio"),
1578
+ command: string().optional(),
1579
+ args: array(string()).default([]),
1580
+ env: NameValueList,
1581
+ url: string().optional(),
1582
+ headers: NameValueList
1583
+ }).superRefine((def, ctx) => {
1584
+ if (def.transport === "stdio" && !def.command) ctx.addIssue({
1585
+ code: "custom",
1586
+ path: ["command"],
1587
+ message: "a stdio MCP server requires \"command\""
1588
+ });
1589
+ if (def.transport !== "stdio" && !def.url) ctx.addIssue({
1590
+ code: "custom",
1591
+ path: ["url"],
1592
+ message: `a ${def.transport} MCP server requires "url"`
1593
+ });
1594
+ });
1595
+ const ConfigSchema = object({
1596
+ version: literal(1),
1597
+ daemonId: string().optional(),
1598
+ webAppUrl: string().optional(),
1599
+ controlPlane: object({
1600
+ enabled: boolean().default(true),
1601
+ url: string().optional(),
1602
+ key: string().optional(),
1603
+ heartbeatMs: number().int().default(15e3)
1604
+ }).default({
1605
+ enabled: false,
1606
+ heartbeatMs: 15e3
1607
+ }),
1608
+ agentsDir: string().optional(),
1609
+ runtimes: record(string(), RuntimeDefSchema).optional(),
1610
+ mcpServers: record(string(), McpServerDefSchema).optional(),
1611
+ relays: array(RelayRosterEntry).default([]),
1612
+ logging: object({ level: _enum([
1613
+ "trace",
1614
+ "debug",
1615
+ "info",
1616
+ "warn",
1617
+ "error"
1618
+ ]).default("info") }).default({ level: "info" }),
1619
+ limits: object({
1620
+ maxAgents: number().int().default(8),
1621
+ maxConcurrentSessions: number().int().default(32),
1622
+ agentIdleTimeoutMs: number().int().default(9e5),
1623
+ idleSweepMs: number().int().default(6e4),
1624
+ shutdownDrainMs: number().int().default(25e3),
1625
+ cancelBackstopMs: number().int().default(3e4),
1626
+ agentStartAttempts: number().int().min(1).default(3),
1627
+ agentStartBackoffMs: number().int().min(0).default(500),
1628
+ maxAttachmentBytes: number().int().default(8 * 1024 * 1024)
1629
+ }).default({
1630
+ maxAgents: 8,
1631
+ maxConcurrentSessions: 32,
1632
+ agentIdleTimeoutMs: 9e5,
1633
+ idleSweepMs: 6e4,
1634
+ shutdownDrainMs: 25e3,
1635
+ cancelBackstopMs: 3e4,
1636
+ agentStartAttempts: 3,
1637
+ agentStartBackoffMs: 500,
1638
+ maxAttachmentBytes: 8 * 1024 * 1024
1639
+ })
1640
+ });
1641
+ //#endregion
1642
+ //#region src/config/load-config.ts
1643
+ function loadConfig(opts = {}) {
1644
+ const root = resolveRoot(opts.root);
1645
+ const file = opts.configPath ?? configPath(root);
1646
+ let raw;
1647
+ if (existsSync(file)) raw = JSON.parse(readFileSync(file, "utf8"));
1648
+ else if (opts.autoCreate) {
1649
+ raw = { version: 1 };
1650
+ mkdirSync(dirname(file), { recursive: true });
1651
+ writeFileSync(file, JSON.stringify(raw, null, 2) + "\n");
1652
+ } else if (opts.optional) raw = { version: 1 };
1653
+ else throw new Error(`config not found: ${file} (create it, pass --config, or run \`agentconnect login\`)`);
1654
+ const cfg = ConfigSchema.parse(raw);
1655
+ const o = opts.overrides ?? {};
1656
+ if (o.daemonId) cfg.daemonId = o.daemonId;
1657
+ if (o.logLevel) cfg.logging.level = o.logLevel;
1658
+ if (o.maxAgents !== void 0) cfg.limits.maxAgents = o.maxAgents;
1659
+ if (o.cpUrl) cfg.controlPlane.url = o.cpUrl;
1660
+ if (o.cpKey) cfg.controlPlane.key = o.cpKey;
1661
+ if (o.cpUrl || o.cpKey) cfg.controlPlane.enabled = true;
1662
+ if (o.noCp) cfg.controlPlane.enabled = false;
1663
+ cfg.agentsDir = o.agentsDir ?? cfg.agentsDir ?? defaultAgentsDir(root);
1664
+ return cfg;
1665
+ }
1666
+ /**
1667
+ * Persist a (freshly-minted) `daemonId` back into config.json so it is stable
1668
+ * per install. Best-effort: a write failure is swallowed (the daemon still runs
1669
+ * with the in-memory id this session).
1670
+ */
1671
+ function persistDaemonId(root, daemonId) {
1672
+ try {
1673
+ const file = configPath(resolveRoot(root));
1674
+ const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { version: 1 };
1675
+ raw.daemonId = daemonId;
1676
+ mkdirSync(dirname(file), { recursive: true });
1677
+ writeFileSync(file, JSON.stringify(raw, null, 2) + "\n");
1678
+ } catch {}
1679
+ }
1680
+ /**
1681
+ * Persist the CP-published relay roster back into config.json so the daemon can
1682
+ * re-dial its relays at boot while the CP is unreachable (graceful degradation).
1683
+ * Whole-set (CP-owned): overwrites any prior value, so a swept relay is cleared.
1684
+ * Best-effort — a write failure is swallowed (the in-memory roster still drives
1685
+ * this session's dials).
1686
+ */
1687
+ function persistRelays(root, relays) {
1688
+ try {
1689
+ const file = configPath(resolveRoot(root));
1690
+ const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { version: 1 };
1691
+ raw.relays = relays;
1692
+ mkdirSync(dirname(file), { recursive: true });
1693
+ writeFileSync(file, JSON.stringify(raw, null, 2) + "\n");
1694
+ } catch {}
1695
+ }
1696
+ //#endregion
1697
+ //#region src/agents/agent-schema.ts
1698
+ const BindMatchSchema = discriminatedUnion("kind", [
1699
+ object({ kind: literal("mention") }),
1700
+ object({ kind: literal("dm") }),
1701
+ object({
1702
+ kind: literal("keyword"),
1703
+ value: string()
1704
+ }),
1705
+ object({ kind: literal("auto") })
1706
+ ]);
1707
+ const BindRuleConfigSchema = object({
1708
+ channel: string().optional(),
1709
+ thread: string().optional(),
1710
+ match: BindMatchSchema
1711
+ });
1712
+ const SlackConfigSchema = object({
1713
+ botToken: string(),
1714
+ appToken: string(),
1715
+ signingSecret: string().optional(),
1716
+ botUserId: string().optional(),
1717
+ allowedUserIds: array(string()).default([]),
1718
+ bindRules: array(BindRuleConfigSchema).default([])
1719
+ });
1720
+ const TelegramConfigSchema = object({
1721
+ botToken: string(),
1722
+ botUserId: string().optional(),
1723
+ botUsername: string().optional(),
1724
+ allowedUserIds: array(string()).default([]),
1725
+ bindRules: array(BindRuleConfigSchema).default([])
1726
+ });
1727
+ const DiscordConfigSchema = object({
1728
+ botToken: string(),
1729
+ applicationId: string().optional(),
1730
+ botUserId: string().optional(),
1731
+ allowedUserIds: array(string()).default([]),
1732
+ bindRules: array(BindRuleConfigSchema).default([])
1733
+ });
1734
+ const IntegrationSchema = discriminatedUnion("platform", [
1735
+ object({
1736
+ id: string(),
1737
+ platform: literal("slack"),
1738
+ slack: SlackConfigSchema
1739
+ }),
1740
+ object({
1741
+ id: string(),
1742
+ platform: literal("telegram"),
1743
+ telegram: TelegramConfigSchema
1744
+ }),
1745
+ object({
1746
+ id: string(),
1747
+ platform: literal("discord"),
1748
+ discord: DiscordConfigSchema
1749
+ })
1750
+ ]);
1751
+ /** A scheduled trigger for THIS agent: every `schedule` tick, prompt the agent
1752
+ * with `trigger`. `target` is optional output routing — when present the daemon
1753
+ * posts the trigger into that channel and the session replies in its thread;
1754
+ * absent ⇒ headless fire (no platform output). `origin:"cp"` marks CP-pushed
1755
+ * entries (written by cron/upsert, pruned by drop.crons); hand-authored entries
1756
+ * have no origin and are never touched by the CP. */
1757
+ const CronDefSchema = object({
1758
+ id: string(),
1759
+ schedule: string(),
1760
+ target: object({
1761
+ platform: _enum([
1762
+ "slack",
1763
+ "telegram",
1764
+ "discord"
1765
+ ]),
1766
+ channel: string(),
1767
+ integrationId: string().optional()
1768
+ }).optional(),
1769
+ trigger: string(),
1770
+ enabled: boolean().default(true),
1771
+ origin: literal("cp").optional()
1772
+ });
1773
+ const AgentSchema = object({
1774
+ id: string(),
1775
+ name: string(),
1776
+ status: _enum([
1777
+ "active",
1778
+ "inactive",
1779
+ "paused"
1780
+ ]).default("active"),
1781
+ pause: boolean().default(false),
1782
+ runtime: string(),
1783
+ description: string().optional(),
1784
+ reasoningEffort: string().optional(),
1785
+ executionMode: string().optional(),
1786
+ fastMode: boolean().optional(),
1787
+ permissionMode: string().default("default"),
1788
+ runtimeOverrides: object({
1789
+ model: string().optional(),
1790
+ env: array(object({
1791
+ name: string(),
1792
+ value: string()
1793
+ })).default([])
1794
+ }).optional(),
1795
+ mcpServers: array(string()).default([]),
1796
+ memory: object({ provider: _enum([
1797
+ "native",
1798
+ "managed",
1799
+ "external"
1800
+ ]).default("managed") }).optional(),
1801
+ workspace: object({
1802
+ mode: _enum(["git-repo", "from-scratch"]),
1803
+ path: string(),
1804
+ gitRepo: string().optional(),
1805
+ gitBranch: string().default("main"),
1806
+ gitCredential: _enum(["github-app"]).optional(),
1807
+ pullOnNewSession: boolean().default(true),
1808
+ skills: array(string()).default([])
1809
+ }),
1810
+ integrations: array(IntegrationSchema).default([]),
1811
+ output: object({ mode: _enum([
1812
+ "low",
1813
+ "medium",
1814
+ "high"
1815
+ ]).default("low") }).default({ mode: "low" }),
1816
+ permissions: object({
1817
+ policy: _enum(["ask", "auto"]).default("ask"),
1818
+ autoApprove: array(string()).default([])
1819
+ }).default({
1820
+ policy: "ask",
1821
+ autoApprove: []
1822
+ }),
1823
+ crons: array(CronDefSchema).default([])
1824
+ });
1825
+ //#endregion
1826
+ //#region src/agents/agent-env.ts
1827
+ function parseDotenv(text) {
1828
+ const out = {};
1829
+ for (const rawLine of text.split("\n")) {
1830
+ const line = rawLine.replace(/\r$/, "").trim();
1831
+ if (!line || line.startsWith("#")) continue;
1832
+ const body = line.startsWith("export ") ? line.slice(7).trim() : line;
1833
+ const eq = body.indexOf("=");
1834
+ if (eq === -1) continue;
1835
+ const key = body.slice(0, eq).trim();
1836
+ if (!key) continue;
1837
+ let value = body.slice(eq + 1).trim();
1838
+ const q = value[0];
1839
+ if (value.length >= 2 && (q === "\"" || q === "'") && value.at(-1) === q) value = value.slice(1, -1);
1840
+ out[key] = value;
1841
+ }
1842
+ return out;
1843
+ }
1844
+ function loadDotenv(dir) {
1845
+ const file = join(dir, ".env");
1846
+ if (!existsSync(file)) return {};
1847
+ return parseDotenv(readFileSync(file, "utf8"));
1848
+ }
1849
+ const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1850
+ function interpolateString(value, source, path, ctx) {
1851
+ return value.replace(VAR_RE, (_m, name) => {
1852
+ const resolved = source[name];
1853
+ if (resolved === void 0) throw new Error(`agent "${ctx.id}": unresolved \${${name}} in ${path} (.env: ${ctx.envPath})`);
1854
+ return resolved;
1855
+ });
1856
+ }
1857
+ function walk(node, source, path, ctx) {
1858
+ if (typeof node === "string") return interpolateString(node, source, path, ctx);
1859
+ if (Array.isArray(node)) {
1860
+ for (let i = 0; i < node.length; i++) node[i] = walk(node[i], source, `${path}[${i}]`, ctx);
1861
+ return node;
1862
+ }
1863
+ if (node && typeof node === "object") {
1864
+ const obj = node;
1865
+ for (const key of Object.keys(obj)) obj[key] = walk(obj[key], source, path ? `${path}.${key}` : key, ctx);
1866
+ return node;
1867
+ }
1868
+ return node;
1869
+ }
1870
+ function interpolateAgent(agent, source, ctx) {
1871
+ walk(agent, source, "", ctx);
1872
+ }
1873
+ function agentChildEnv(agent) {
1874
+ return {
1875
+ ...agent.env,
1876
+ ...Object.fromEntries((agent.runtimeOverrides?.env ?? []).map((e) => [e.name, e.value]))
1877
+ };
1878
+ }
1879
+ //#endregion
1880
+ //#region src/agents/load-agents.ts
1881
+ const IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
1882
+ const MAX_DEPTH = 4;
1883
+ function parseAgentFile(file) {
1884
+ const dir = dirname(file);
1885
+ const env = loadDotenv(dir);
1886
+ const agent = AgentSchema.parse(JSON.parse(readFileSync(file, "utf8")));
1887
+ interpolateAgent(agent, {
1888
+ ...process.env,
1889
+ ...env
1890
+ }, {
1891
+ id: agent.id,
1892
+ envPath: join(dir, ".env")
1893
+ });
1894
+ if (!isAbsolute(agent.workspace.path)) agent.workspace.path = resolve(dir, agent.workspace.path);
1895
+ return {
1896
+ ...agent,
1897
+ dir,
1898
+ env
1899
+ };
1900
+ }
1901
+ function findAgentFiles(dir, depth = 0) {
1902
+ if (depth > MAX_DEPTH || !existsSync(dir)) return [];
1903
+ let entries;
1904
+ try {
1905
+ entries = readdirSync(dir, { withFileTypes: true });
1906
+ } catch {
1907
+ return [];
1908
+ }
1909
+ if (entries.some((e) => e.isFile() && e.name === "agent.json")) return [join(dir, "agent.json")];
1910
+ const out = [];
1911
+ for (const entry of entries) {
1912
+ if (!entry.isDirectory()) continue;
1913
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
1914
+ out.push(...findAgentFiles(join(dir, entry.name), depth + 1));
1915
+ }
1916
+ return out;
1917
+ }
1918
+ function discoverAgents(agentsDir) {
1919
+ return findAgentFiles(agentsDir).map((file) => {
1920
+ try {
1921
+ return {
1922
+ agent: parseAgentFile(file),
1923
+ dir: dirname(file)
1924
+ };
1925
+ } catch (err) {
1926
+ throw new Error(`invalid agent.json at ${file}: ${err.message}`);
1927
+ }
1928
+ });
1929
+ }
1930
+ function loadAgents(agentsDir) {
1931
+ return discoverAgents(agentsDir).map((d) => d.agent).filter((a) => a.status === "active");
1932
+ }
1933
+ function selectAgent(agentsDir, name) {
1934
+ const agents = discoverAgents(agentsDir).map((d) => d.agent);
1935
+ if (name) {
1936
+ const match = agents.find((a) => a.id === name);
1937
+ if (!match) {
1938
+ const available = agents.map((a) => a.id).sort().join(", ") || "(none)";
1939
+ throw new Error(`agent "${name}" not found in ${agentsDir}. Available: ${available}`);
1940
+ }
1941
+ return match;
1942
+ }
1943
+ if (agents.length === 0) throw new Error(`no agent.json found in ${agentsDir}`);
1944
+ if (agents.length > 1) {
1945
+ const ids = agents.map((a) => a.id).sort().join(", ");
1946
+ throw new Error(`multiple agents found in ${agentsDir}: ${ids}; use --agent <name> to specify one`);
1947
+ }
1948
+ return agents[0];
1949
+ }
1950
+ //#endregion
1951
+ export { decodeEnvelope as _, agentChildEnv as a, MEMORY_INDEX as b, persistRelays as c, normalizeGitUrl as d, RELAY_DAEMON_SUBPROTOCOL as f, buildEnvelope as g, decodeRelayDaemonFrame as h, selectAgent as i, ConfigSchema as l, buildRelayDaemonFrame as m, findAgentFiles as n, loadConfig as o, RELAY_DAEMON_WS_PATH as p, loadAgents as r, persistDaemonId as s, discoverAgents as t, isFrame as u, encode as v, RESERVED_MCP_SERVER_NAME as x, MAX_FRAME_BYTES as y };
1952
+
1953
+ //# sourceMappingURL=load-agents-NvLpcg5H.js.map