@spinabot/brigade 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/convex/logs.d.ts +9 -9
  2. package/convex/memory.d.ts +21 -21
  3. package/convex/schema.d.ts +11 -11
  4. package/convex/skills.d.ts +3 -3
  5. package/dist/buildstamp.json +1 -1
  6. package/dist/cli/commands/config-cmd.d.ts +12 -19
  7. package/dist/cli/commands/config-cmd.d.ts.map +1 -1
  8. package/dist/cli/commands/config-cmd.js +14 -197
  9. package/dist/cli/commands/config-cmd.js.map +1 -1
  10. package/dist/cli/commands/connect-transcript.d.ts +39 -0
  11. package/dist/cli/commands/connect-transcript.d.ts.map +1 -0
  12. package/dist/cli/commands/connect-transcript.js +60 -0
  13. package/dist/cli/commands/connect-transcript.js.map +1 -0
  14. package/dist/cli/commands/connect.d.ts.map +1 -1
  15. package/dist/cli/commands/connect.js +297 -169
  16. package/dist/cli/commands/connect.js.map +1 -1
  17. package/dist/core/agents-crud-ops.d.ts +15 -0
  18. package/dist/core/agents-crud-ops.d.ts.map +1 -0
  19. package/dist/core/agents-crud-ops.js +27 -0
  20. package/dist/core/agents-crud-ops.js.map +1 -0
  21. package/dist/core/agents-ops.d.ts +43 -0
  22. package/dist/core/agents-ops.d.ts.map +1 -0
  23. package/dist/core/agents-ops.js +117 -0
  24. package/dist/core/agents-ops.js.map +1 -0
  25. package/dist/core/channels-ops.d.ts +30 -0
  26. package/dist/core/channels-ops.d.ts.map +1 -0
  27. package/dist/core/channels-ops.js +52 -0
  28. package/dist/core/channels-ops.js.map +1 -0
  29. package/dist/core/config-ops.d.ts +77 -0
  30. package/dist/core/config-ops.d.ts.map +1 -0
  31. package/dist/core/config-ops.js +241 -0
  32. package/dist/core/config-ops.js.map +1 -0
  33. package/dist/core/exec-ops.d.ts +48 -0
  34. package/dist/core/exec-ops.d.ts.map +1 -0
  35. package/dist/core/exec-ops.js +101 -0
  36. package/dist/core/exec-ops.js.map +1 -0
  37. package/dist/core/integrations-ops.d.ts +25 -0
  38. package/dist/core/integrations-ops.d.ts.map +1 -0
  39. package/dist/core/integrations-ops.js +40 -0
  40. package/dist/core/integrations-ops.js.map +1 -0
  41. package/dist/core/memory-ops.d.ts +20 -0
  42. package/dist/core/memory-ops.d.ts.map +1 -0
  43. package/dist/core/memory-ops.js +40 -0
  44. package/dist/core/memory-ops.js.map +1 -0
  45. package/dist/core/pairing-ops.d.ts +33 -0
  46. package/dist/core/pairing-ops.d.ts.map +1 -0
  47. package/dist/core/pairing-ops.js +78 -0
  48. package/dist/core/pairing-ops.js.map +1 -0
  49. package/dist/core/provider-ops.d.ts +17 -0
  50. package/dist/core/provider-ops.d.ts.map +1 -0
  51. package/dist/core/provider-ops.js +29 -0
  52. package/dist/core/provider-ops.js.map +1 -0
  53. package/dist/core/server.d.ts.map +1 -1
  54. package/dist/core/server.js +261 -14
  55. package/dist/core/server.js.map +1 -1
  56. package/dist/core/sessions-ops.d.ts +25 -0
  57. package/dist/core/sessions-ops.d.ts.map +1 -0
  58. package/dist/core/sessions-ops.js +77 -0
  59. package/dist/core/sessions-ops.js.map +1 -0
  60. package/dist/core/skills-ops.d.ts +14 -0
  61. package/dist/core/skills-ops.d.ts.map +1 -0
  62. package/dist/core/skills-ops.js +28 -0
  63. package/dist/core/skills-ops.js.map +1 -0
  64. package/dist/protocol/errors.d.ts +14 -0
  65. package/dist/protocol/errors.d.ts.map +1 -1
  66. package/dist/protocol/errors.js +14 -0
  67. package/dist/protocol/errors.js.map +1 -1
  68. package/dist/protocol/handshake.d.ts +10 -0
  69. package/dist/protocol/handshake.d.ts.map +1 -1
  70. package/dist/protocol/methods.d.ts +478 -0
  71. package/dist/protocol/methods.d.ts.map +1 -1
  72. package/dist/protocol/stream-seq.d.ts +30 -0
  73. package/dist/protocol/stream-seq.d.ts.map +1 -0
  74. package/dist/protocol/stream-seq.js +38 -0
  75. package/dist/protocol/stream-seq.js.map +1 -0
  76. package/dist/protocol.d.ts +265 -6
  77. package/dist/protocol.d.ts.map +1 -1
  78. package/dist/protocol.js +95 -5
  79. package/dist/protocol.js.map +1 -1
  80. package/dist/tui/client.d.ts +67 -2
  81. package/dist/tui/client.d.ts.map +1 -1
  82. package/dist/tui/client.js +106 -2
  83. package/dist/tui/client.js.map +1 -1
  84. package/package.json +1 -1
@@ -28,7 +28,9 @@ import { createServer as createTcpServer } from "node:net";
28
28
  import { pathToFileURL } from "node:url";
29
29
  import { ModelRegistry, } from "@earendil-works/pi-coding-agent";
30
30
  import { WebSocketServer } from "ws";
31
- import { DEFAULT_PORT, isFrame, modelToSummary, TICK_INTERVAL_MS, } from "../protocol.js";
31
+ import { DEFAULT_PORT, EVENT_NAMES, isFrame, modelToSummary, REQUEST_METHODS, TICK_INTERVAL_MS, } from "../protocol.js";
32
+ import { PROTOCOL_VERSION } from "../protocol/handshake.js";
33
+ import { nextSeq } from "../protocol/stream-seq.js";
32
34
  // Per-turn execution path (the single canonical runtime). The gateway no
33
35
  // longer holds a long-lived Pi session: every inbound `prompt` builds a
34
36
  // fresh session via `runResilientTurn`, resumes the JSONL transcript by
@@ -135,6 +137,17 @@ import { mutateConfigAtomic } from "../config/io.js";
135
137
  import { acquireGatewayLock } from "./gateway-lock.js";
136
138
  import { clearHeartbeatFile, clearPidFile, writeHeartbeatFile, writePidFile } from "./gateway-probe.js";
137
139
  import { extractToken, matchesAnyToken, resolveGatewayAuth } from "./gateway-auth.js";
140
+ import { handleConfigGet, handleConfigList, handleConfigSchema, handleConfigSet, handleConfigUnset, handleConfigValidate, } from "./config-ops.js";
141
+ import { handleExecAllow, handleExecAllowPattern, handleExecDenyTest, handleExecList, handleExecRemove, } from "./exec-ops.js";
142
+ import { handleAgentsBind, handleAgentsBindings, handleAgentsUnbind } from "./agents-ops.js";
143
+ import { handlePairingApprove, handlePairingList, handlePairingRevoke } from "./pairing-ops.js";
144
+ import { handleSessionsCleanup } from "./sessions-ops.js";
145
+ import { handleMemoryManage, handleMemoryWrite } from "./memory-ops.js";
146
+ import { handleAgentsAdd, handleAgentsDelete, handleAgentsSetIdentity } from "./agents-crud-ops.js";
147
+ import { handleSkillsCreate, handleSkillsDelete, handleSkillsWriteFile } from "./skills-ops.js";
148
+ import { handleChannelsAllowAdd, handleChannelsAllowList, handleChannelsAllowRemove, handleChannelsConnect, handleChannelsDisconnect, } from "./channels-ops.js";
149
+ import { handleProviderRemove } from "./provider-ops.js";
150
+ import { handleComposio, handleOauth } from "./integrations-ops.js";
138
151
  // Persist a model selection to brigade.json's new wizard-shape (the lifted
139
152
  // code expected the older flat `defaultProvider`/`defaultModelId` fields).
140
153
  // Writes through the same `agents.defaults.{provider, model.primary}` path
@@ -1491,6 +1504,32 @@ async function continueBoot(args) {
1491
1504
  const clientConnIds = new WeakMap();
1492
1505
  const clientAgentSubs = new Map();
1493
1506
  const clientSessionSubs = new Map();
1507
+ /**
1508
+ * Per-session monotonic sequence for the ordered, recoverable stream.
1509
+ * `broadcast` stamps the next value onto every ORDERED frame tagged with a
1510
+ * sessionId (= sessionKey): top-level `pi`, `approval-request`, and
1511
+ * `system-event` (they SHARE one counter per session, so a client detects a
1512
+ * gap in any of them and `resume`s). A client tracks the last seq it saw per
1513
+ * session; a jump means it missed a frame. `resume` returns the current value
1514
+ * as `headSeq`. One int per session — negligible; never pruned so a session's
1515
+ * seq stays monotonic across turns. A gateway restart resets these to 0 — the
1516
+ * client detects that via the `epoch` change on its next `HelloOk` and
1517
+ * invalidates its cursor.
1518
+ */
1519
+ const seqCounters = new Map();
1520
+ /**
1521
+ * Bounded per-session tail of recent `system-event` notices (cron announces /
1522
+ * channel-health), so a client that was disconnected when one fired can still
1523
+ * recover it via `resume`. Oldest-first, capped at RECENT_SYSTEM_EVENTS_MAX.
1524
+ */
1525
+ const recentSystemEvents = new Map();
1526
+ const RECENT_SYSTEM_EVENTS_MAX = 30;
1527
+ /**
1528
+ * Process boot id (session generation / "epoch"). Constant for this gateway
1529
+ * process; a restart yields a new value. Advertised in `HelloOk` so a client
1530
+ * can tell a restart (→ invalidate seq cursors) from a normal reconnect.
1531
+ */
1532
+ const gatewayEpoch = crypto.randomUUID();
1494
1533
  const subscribeAgent = (connId, agentIdValue) => {
1495
1534
  let set = clientAgentSubs.get(connId);
1496
1535
  if (!set) {
@@ -1522,13 +1561,42 @@ async function continueBoot(args) {
1522
1561
  const connWantsFrame = (connId, frameAgentId, frameSessionId) => shouldDeliverFrame(clientAgentSubs.get(connId), clientSessionSubs.get(connId), { agentId: frameAgentId, sessionId: frameSessionId });
1523
1562
  /** Send one event to all connected clients (or a filtered subset). */
1524
1563
  const broadcast = (event, payload) => {
1525
- const frame = { type: "event", event, payload };
1526
- const json = JSON.stringify(frame);
1527
1564
  // Untagged payloads broadcast to everyone (state, error, basic log).
1528
1565
  // Tagged payloads (pi, log with agent/session, approval-request,
1529
1566
  // system-event with target) consult the subscription filter so the
1530
1567
  // approval prompt for agent A doesn't pop on operator B's TUI.
1531
1568
  const { agentId: frameAgentId, sessionId: frameSessionId } = extractFrameTags(payload);
1569
+ // Stamp a per-session monotonic seq on the ordered transcript stream
1570
+ // (`pi`). This is the gap detector: a client that sees seq jump knows it
1571
+ // missed a frame and issues `resume`. Only `pi` frames carry seq — they
1572
+ // are the transcript; state/error/log are unordered side-channels a
1573
+ // client never gap-checks. Same `json` goes to every subscriber, so the
1574
+ // seq is shared across all clients watching this session.
1575
+ //
1576
+ // The ordered, recoverable stream = top-level `pi` + `approval-request` +
1577
+ // `system-event`, sharing one per-session counter so a client detects a
1578
+ // gap in ANY of them and `resume`s. EXCLUDED (no seq):
1579
+ // - sub-agent `pi` frames (subagentDepth>0): they carry the child's own
1580
+ // session id (a UUID) and live in a separate child transcript the
1581
+ // parent's `resume` can't backfill — ephemeral nested decoration.
1582
+ // - `state` (self-healing cumulative snapshot), `error`, `log` (on disk).
1583
+ const subDepth = event === "pi" ? Number(payload.subagentDepth) || 0 : 0;
1584
+ const isOrderedFrame = (event === "pi" && subDepth === 0) ||
1585
+ event === "approval-request" ||
1586
+ event === "system-event";
1587
+ const seq = isOrderedFrame ? nextSeq(seqCounters, frameSessionId) : undefined;
1588
+ // Retain a bounded per-session tail of system-events for `resume` recovery.
1589
+ if (event === "system-event" && frameSessionId) {
1590
+ const ring = recentSystemEvents.get(frameSessionId) ?? [];
1591
+ ring.push(payload);
1592
+ while (ring.length > RECENT_SYSTEM_EVENTS_MAX)
1593
+ ring.shift();
1594
+ recentSystemEvents.set(frameSessionId, ring);
1595
+ }
1596
+ const frame = seq !== undefined
1597
+ ? { type: "event", event, payload, seq }
1598
+ : { type: "event", event, payload };
1599
+ const json = JSON.stringify(frame);
1532
1600
  for (const ws of clients) {
1533
1601
  if (ws.readyState !== ws.OPEN)
1534
1602
  continue;
@@ -2902,6 +2970,57 @@ async function continueBoot(args) {
2902
2970
  case "get-state": {
2903
2971
  return buildSnapshot();
2904
2972
  }
2973
+ case "resume": {
2974
+ // Reliable-streaming recovery. Return the session's committed
2975
+ // transcript (the single source of truth — works in BOTH
2976
+ // filesystem + Convex mode via `readSessionTranscriptMessages`)
2977
+ // plus the session's current head seq and the header snapshot.
2978
+ // The client re-materialises from this on (re)connect or a
2979
+ // detected seq gap, then keeps applying live `pi` frames keyed by
2980
+ // identity — so a dropped/reordered frame self-heals. Any
2981
+ // in-flight (not-yet-committed) message is NOT in the transcript
2982
+ // yet; the live `message_update` stream paints it after resume and
2983
+ // the identity-keyed renderer dedupes it on commit. Read-only;
2984
+ // default-pass session guard (the local WS client is the operator).
2985
+ const guardErr = defaultPassSessionGuard(rawParams, "list");
2986
+ if (guardErr)
2987
+ throw guardErr;
2988
+ const p = (params ?? {});
2989
+ const targetAgentId = p.agentId?.trim() || agentId;
2990
+ const targetSessionKey = p.sessionKey?.trim() || defaultSessionKey(targetAgentId);
2991
+ const messages = await readSessionTranscriptMessages({ sessionKey: targetSessionKey });
2992
+ const headSeq = seqCounters.get(targetSessionKey) ?? 0;
2993
+ // Recovery for the two non-transcript event types so a (re)connecting
2994
+ // client loses NOTHING: tool-approval prompts still pending on this
2995
+ // session (else the turn hangs to auto-deny), and the recent
2996
+ // system-event tail. Pending approvals are filtered to this session.
2997
+ const pendingApprovals = approvalBridge
2998
+ .listPending()
2999
+ .filter((a) => a.sessionId === targetSessionKey)
3000
+ .map((a) => ({
3001
+ id: a.id,
3002
+ command: a.command,
3003
+ toolName: a.toolName,
3004
+ timeoutMs: a.timeoutMs,
3005
+ decisions: a.decisions,
3006
+ ...(a.cwd !== undefined ? { cwd: a.cwd } : {}),
3007
+ ...(a.subagentLabel !== undefined ? { subagentLabel: a.subagentLabel } : {}),
3008
+ ...(a.subagentDepth !== undefined ? { subagentDepth: a.subagentDepth } : {}),
3009
+ ...(a.parentRunId !== undefined ? { parentRunId: a.parentRunId } : {}),
3010
+ ...(a.agentId !== undefined ? { agentId: a.agentId } : {}),
3011
+ ...(a.sessionId !== undefined ? { sessionId: a.sessionId } : {}),
3012
+ }));
3013
+ return {
3014
+ sessionKey: targetSessionKey,
3015
+ agentId: targetAgentId,
3016
+ messages: messages,
3017
+ headSeq,
3018
+ pendingApprovals,
3019
+ recentSystemEvents: recentSystemEvents.get(targetSessionKey) ?? [],
3020
+ epoch: gatewayEpoch,
3021
+ snapshot: buildSnapshot(targetAgentId),
3022
+ };
3023
+ }
2905
3024
  case "memory-graph": {
2906
3025
  // Memory Graph dashboard data — nodes + typed edges + topic clusters
2907
3026
  // + stats, for an agent's workspace. Read; default-pass access guard
@@ -3106,8 +3225,32 @@ async function continueBoot(args) {
3106
3225
  // already enforced regardless, so plugins that declare a scope
3107
3226
  // today won't need a code change when multi-user lands.
3108
3227
  const caller = { id: "local", scopes: ["operator.admin", "operator.write", "operator.read"] };
3109
- // Send the initial snapshot so the client can render its header
3110
- // before any user action.
3228
+ // Champion-tier handshake: the FIRST frame is `hello-ok`, handing the
3229
+ // client everything it needs to subscribe without hardcoding — the
3230
+ // protocol version, its connId, the gateway's build version + epoch
3231
+ // (session generation, for restart detection), the full list of callable
3232
+ // methods (core wire methods + registered control-plane RPCs) and
3233
+ // subscribable events, and the policy limits (payload/buffer caps + tick
3234
+ // interval). A client that ignores it still works (the `state` frame
3235
+ // below preserves the legacy boot path).
3236
+ const helloOk = {
3237
+ type: "hello-ok",
3238
+ protocol: PROTOCOL_VERSION,
3239
+ server: { version: getBuildInfo().version, connId, epoch: gatewayEpoch },
3240
+ features: {
3241
+ methods: [...REQUEST_METHODS, ...customMethods.keys()],
3242
+ events: [...EVENT_NAMES],
3243
+ },
3244
+ policy: {
3245
+ maxPayload: MAX_WS_PAYLOAD_BYTES,
3246
+ maxBufferedBytes: MAX_WS_BUFFERED_BYTES,
3247
+ tickIntervalMs: TICK_INTERVAL_MS,
3248
+ },
3249
+ auth: { role: "operator" },
3250
+ };
3251
+ ws.send(JSON.stringify(helloOk));
3252
+ // Then the initial snapshot so the client can render its header before
3253
+ // any user action (also the back-compat boot frame for older clients).
3111
3254
  ws.send(JSON.stringify({ type: "event", event: "state", payload: buildSnapshot() }));
3112
3255
  // Per-connection ring of RPC timestamps powering the sliding-window check.
3113
3256
  const rateRing = [];
@@ -3247,16 +3390,31 @@ async function continueBoot(args) {
3247
3390
  });
3248
3391
  });
3249
3392
  /* ──────────────── tick heartbeat ──────────────── */
3250
- // Push an empty `state` snapshot every TICK_INTERVAL_MS so clients can
3251
- // detect a dead server (no frames in this interval = close + reconnect).
3252
- // Sending the snapshot doubles as keep-alive AND consistency check.
3253
- //
3254
- // The tick is also the heartbeat-file beat: refreshing the heartbeat from
3255
- // inside the event-loop tick proves the loop is healthy. A process whose
3256
- // loop is starved (deadlock, runaway sync compute, exhausted FDs) misses
3257
- // the refresh and the external supervisor restarts it.
3393
+ // Send a raw (non-`event`) frame to every open client. Used for the cheap
3394
+ // `tick` keepalive + the graceful `shutdown` notice each a single tiny
3395
+ // frame, so no backpressure gate (the ping reaper handles a truly dead one).
3396
+ const sendRawToAll = (frame) => {
3397
+ const json = JSON.stringify(frame);
3398
+ for (const ws of clients) {
3399
+ if (ws.readyState !== ws.OPEN)
3400
+ continue;
3401
+ try {
3402
+ ws.send(json);
3403
+ }
3404
+ catch {
3405
+ /* best-effort */
3406
+ }
3407
+ }
3408
+ };
3409
+ // Emit a cheap `tick` frame every TICK_INTERVAL_MS so clients detect a dead
3410
+ // server (no frames in 2× this interval = close + reconnect). Was a full
3411
+ // `state` snapshot to every binding; a tick is far lighter (battery/bandwidth
3412
+ // on mobile) and `state` is still pushed on every real mutation + on connect,
3413
+ // so idle clients stay consistent. The tick also doubles as the heartbeat-file
3414
+ // beat: refreshing it from inside the event-loop tick proves the loop is
3415
+ // healthy — a starved loop misses it and the supervisor restarts the process.
3258
3416
  const tickTimer = setInterval(() => {
3259
- broadcastStateAllBindings();
3417
+ sendRawToAll({ type: "tick", ts: Date.now() });
3260
3418
  void writeHeartbeatFile().catch(() => {
3261
3419
  /* best-effort */
3262
3420
  });
@@ -4093,6 +4251,86 @@ async function continueBoot(args) {
4093
4251
  disposeHandlers.push(registerGatewayHandler("org.snapshot", (_params) => handleOrgSnapshot(undefined, {
4094
4252
  loadConfig: () => loadConfig(),
4095
4253
  })));
4254
+ // `config.*` — operator-level config CRUD over the wire (the `brigade
4255
+ // config` CLI, reachable from a remote client). Path/value/redact shape:
4256
+ // never session-targeted, so the guard-sweep correctly needs no per-session
4257
+ // access check. Reads/writes go through the mode-aware loadConfig/saveConfig,
4258
+ // so this works in filesystem AND Convex mode.
4259
+ disposeHandlers.push(registerGatewayHandler("config.get", handleConfigGet));
4260
+ disposeHandlers.push(registerGatewayHandler("config.set", handleConfigSet));
4261
+ disposeHandlers.push(registerGatewayHandler("config.unset", handleConfigUnset));
4262
+ disposeHandlers.push(registerGatewayHandler("config.list", handleConfigList));
4263
+ disposeHandlers.push(registerGatewayHandler("config.schema", handleConfigSchema));
4264
+ disposeHandlers.push(registerGatewayHandler("config.validate", handleConfigValidate));
4265
+ // `exec.*` — operator-level exec-approval allowlist CRUD (the `brigade exec`
4266
+ // CLI over the wire). Per-agent + operator-scoped (the operator manages
4267
+ // their OWN agents' bash-approval allowlist), the same posture as the
4268
+ // allowlisted exec-allow-all / exec-grant-skill RPCs — no per-session guard
4269
+ // (see ALLOWLIST_NO_GUARD_NEEDED in server.guard-sweep.test.ts). The
4270
+ // hard-deny safety net in exec-approvals.ts still applies on every allow.
4271
+ disposeHandlers.push(registerGatewayHandler("exec.list", handleExecList));
4272
+ disposeHandlers.push(registerGatewayHandler("exec.allow", handleExecAllow));
4273
+ disposeHandlers.push(registerGatewayHandler("exec.allow-pattern", handleExecAllowPattern));
4274
+ disposeHandlers.push(registerGatewayHandler("exec.remove", handleExecRemove));
4275
+ disposeHandlers.push(registerGatewayHandler("exec.deny-test", handleExecDenyTest));
4276
+ // `agents.*` — operator-level routing-binding management (which agent owns
4277
+ // which channel/account). The genuine no-other-path gap: agent add/delete/
4278
+ // set-identity are already reachable via the `manage_agent` tool, but
4279
+ // bindings had no remote path. Operator-scoped config mutation, no per-
4280
+ // session guard (allowlisted in server.guard-sweep.test.ts).
4281
+ disposeHandlers.push(registerGatewayHandler("agents.bindings", handleAgentsBindings));
4282
+ disposeHandlers.push(registerGatewayHandler("agents.bind", handleAgentsBind));
4283
+ disposeHandlers.push(registerGatewayHandler("agents.unbind", handleAgentsUnbind));
4284
+ // `pairing.*` — operator-level channel pairing (approve/revoke strangers who
4285
+ // DM the bot). Per-channel + operator-scoped, no per-session guard. The RPCs
4286
+ // require an explicit channel (a client gets the channel list from
4287
+ // system.capabilities), unlike the CLI's single-channel auto-pick.
4288
+ disposeHandlers.push(registerGatewayHandler("pairing.list", handlePairingList));
4289
+ disposeHandlers.push(registerGatewayHandler("pairing.approve", handlePairingApprove));
4290
+ disposeHandlers.push(registerGatewayHandler("pairing.revoke", handlePairingRevoke));
4291
+ // `sessions.cleanup` — operator maintenance: delete an agent's stale idle
4292
+ // transcript files (the gateway regenerates the store entry on next access).
4293
+ // NOT session-content access (unlike sessions.list/history), so no per-
4294
+ // session guard (allowlisted in server.guard-sweep.test.ts).
4295
+ disposeHandlers.push(registerGatewayHandler("sessions.cleanup", handleSessionsCleanup));
4296
+ // `memory.*` — Tideline write + governance (write_memory / manage_memory).
4297
+ // Memory lives in facts.jsonl (NOT config), so config.set can't reach it;
4298
+ // these are the only typed remote path to MUTATE memory (read is covered by
4299
+ // memory-query / memory-graph). Operator-scoped owner origin, no per-session
4300
+ // guard (allowlisted in server.guard-sweep.test.ts).
4301
+ disposeHandlers.push(registerGatewayHandler("memory.write", handleMemoryWrite));
4302
+ disposeHandlers.push(registerGatewayHandler("memory.manage", handleMemoryManage));
4303
+ // agents.add/delete/set-identity — agent CRUD (reuses the manage_agent tool,
4304
+ // which wraps `brigade agents add/delete/set-identity`). Seeds/soft-deletes a
4305
+ // workspace, so config.set alone can't do it. Operator-scoped (allowlisted).
4306
+ disposeHandlers.push(registerGatewayHandler("agents.add", handleAgentsAdd));
4307
+ disposeHandlers.push(registerGatewayHandler("agents.delete", handleAgentsDelete));
4308
+ disposeHandlers.push(registerGatewayHandler("agents.set-identity", handleAgentsSetIdentity));
4309
+ // skills.create/delete/write-file — skill authoring (reuses the manage_skill
4310
+ // tool). SKILL.md files on disk, not config. (status/install/update already
4311
+ // cover read/install/enable.) Operator-scoped (allowlisted).
4312
+ disposeHandlers.push(registerGatewayHandler("skills.create", handleSkillsCreate));
4313
+ disposeHandlers.push(registerGatewayHandler("skills.delete", handleSkillsDelete));
4314
+ disposeHandlers.push(registerGatewayHandler("skills.write-file", handleSkillsWriteFile));
4315
+ // channels.* — LIVE connect/disconnect (runtime adapter via the global
4316
+ // channel manager) + DM allow-from (a per-channel file store, not config).
4317
+ // Channel enable/disable/policy are already config.set-reachable. Operator-
4318
+ // scoped (allowlisted). connect reuses the owner-scoped connect_channel tool.
4319
+ disposeHandlers.push(registerGatewayHandler("channels.connect", handleChannelsConnect));
4320
+ disposeHandlers.push(registerGatewayHandler("channels.disconnect", handleChannelsDisconnect));
4321
+ disposeHandlers.push(registerGatewayHandler("channels.allow-add", handleChannelsAllowAdd));
4322
+ disposeHandlers.push(registerGatewayHandler("channels.allow-remove", handleChannelsAllowRemove));
4323
+ disposeHandlers.push(registerGatewayHandler("channels.allow-list", handleChannelsAllowList));
4324
+ // provider.remove — delete a provider key (auth-profiles.json, not config;
4325
+ // add-provider exists, removal had no gateway path). Operator-scoped.
4326
+ disposeHandlers.push(registerGatewayHandler("provider.remove", handleProviderRemove));
4327
+ // composio + oauth — integrations. `composio` is remote-clean (Composio
4328
+ // hosts the OAuth callback; the gateway hands over a click-link + polls).
4329
+ // `oauth` is the DIY loopback flow (callback on the gateway host — completes
4330
+ // only for a local/tunneled operator; status/token work remotely). Both
4331
+ // reuse the owner-scoped tools. Operator-scoped (allowlisted).
4332
+ disposeHandlers.push(registerGatewayHandler("composio", handleComposio));
4333
+ disposeHandlers.push(registerGatewayHandler("oauth", handleOauth));
4096
4334
  // Wave O0.8 GAP 11 — opt the session inbox into JSONL persistence at
4097
4335
  // gateway boot. The disk write surface defaults off so the existing
4098
4336
  // unit-test fleet (which doesn't tempdir-isolate ~/.brigade) keeps
@@ -4955,6 +5193,15 @@ async function continueBoot(args) {
4955
5193
  catch {
4956
5194
  /* best-effort */
4957
5195
  }
5196
+ // Tell connected clients we're going down gracefully BEFORE the
5197
+ // sockets close, so a web/mobile UI shows "reconnecting…" and
5198
+ // pre-empts the resume instead of treating the drop as an error.
5199
+ try {
5200
+ sendRawToAll({ type: "shutdown", reason: "gateway shutting down" });
5201
+ }
5202
+ catch {
5203
+ /* best-effort */
5204
+ }
4958
5205
  // Stop the heartbeat runner first so its wake handler unregisters
4959
5206
  // before the rest of the wires unwire — prevents a late wake
4960
5207
  // from firing through a half-torn-down dispatcher.