@jini-ai/http-kit 0.2.1

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 (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +107 -0
  3. package/dist/active-context.d.ts +99 -0
  4. package/dist/active-context.d.ts.map +1 -0
  5. package/dist/active-context.js +77 -0
  6. package/dist/active-context.js.map +1 -0
  7. package/dist/adapter.d.ts +36 -0
  8. package/dist/adapter.d.ts.map +1 -0
  9. package/dist/adapter.js +92 -0
  10. package/dist/adapter.js.map +1 -0
  11. package/dist/agents.d.ts +55 -0
  12. package/dist/agents.d.ts.map +1 -0
  13. package/dist/agents.js +23 -0
  14. package/dist/agents.js.map +1 -0
  15. package/dist/api-security-middleware.d.ts +146 -0
  16. package/dist/api-security-middleware.d.ts.map +1 -0
  17. package/dist/api-security-middleware.js +276 -0
  18. package/dist/api-security-middleware.js.map +1 -0
  19. package/dist/attachments.d.ts +317 -0
  20. package/dist/attachments.d.ts.map +1 -0
  21. package/dist/attachments.js +664 -0
  22. package/dist/attachments.js.map +1 -0
  23. package/dist/cancel-owned-runs.d.ts +44 -0
  24. package/dist/cancel-owned-runs.d.ts.map +1 -0
  25. package/dist/cancel-owned-runs.js +35 -0
  26. package/dist/cancel-owned-runs.js.map +1 -0
  27. package/dist/compat.d.ts +22 -0
  28. package/dist/compat.d.ts.map +1 -0
  29. package/dist/compat.js +17 -0
  30. package/dist/compat.js.map +1 -0
  31. package/dist/component-catalog.d.ts +74 -0
  32. package/dist/component-catalog.d.ts.map +1 -0
  33. package/dist/component-catalog.js +74 -0
  34. package/dist/component-catalog.js.map +1 -0
  35. package/dist/connectors.d.ts +165 -0
  36. package/dist/connectors.d.ts.map +1 -0
  37. package/dist/connectors.js +474 -0
  38. package/dist/connectors.js.map +1 -0
  39. package/dist/daemon-status.d.ts +72 -0
  40. package/dist/daemon-status.d.ts.map +1 -0
  41. package/dist/daemon-status.js +60 -0
  42. package/dist/daemon-status.js.map +1 -0
  43. package/dist/db-ops.d.ts +116 -0
  44. package/dist/db-ops.d.ts.map +1 -0
  45. package/dist/db-ops.js +191 -0
  46. package/dist/db-ops.js.map +1 -0
  47. package/dist/delegated-tools.d.ts +46 -0
  48. package/dist/delegated-tools.d.ts.map +1 -0
  49. package/dist/delegated-tools.js +155 -0
  50. package/dist/delegated-tools.js.map +1 -0
  51. package/dist/express/run-stream.d.ts +15 -0
  52. package/dist/express/run-stream.d.ts.map +1 -0
  53. package/dist/express/run-stream.js +11 -0
  54. package/dist/express/run-stream.js.map +1 -0
  55. package/dist/express-index.d.ts +32 -0
  56. package/dist/express-index.d.ts.map +1 -0
  57. package/dist/express-index.js +14 -0
  58. package/dist/express-index.js.map +1 -0
  59. package/dist/fastify/adapter.d.ts +31 -0
  60. package/dist/fastify/adapter.d.ts.map +1 -0
  61. package/dist/fastify/adapter.js +64 -0
  62. package/dist/fastify/adapter.js.map +1 -0
  63. package/dist/fastify/agents.d.ts +13 -0
  64. package/dist/fastify/agents.d.ts.map +1 -0
  65. package/dist/fastify/agents.js +7 -0
  66. package/dist/fastify/agents.js.map +1 -0
  67. package/dist/fastify/api-security-middleware.d.ts +64 -0
  68. package/dist/fastify/api-security-middleware.d.ts.map +1 -0
  69. package/dist/fastify/api-security-middleware.js +139 -0
  70. package/dist/fastify/api-security-middleware.js.map +1 -0
  71. package/dist/fastify/compat.d.ts +22 -0
  72. package/dist/fastify/compat.d.ts.map +1 -0
  73. package/dist/fastify/compat.js +16 -0
  74. package/dist/fastify/compat.js.map +1 -0
  75. package/dist/fastify/daemon-status.d.ts +22 -0
  76. package/dist/fastify/daemon-status.d.ts.map +1 -0
  77. package/dist/fastify/daemon-status.js +9 -0
  78. package/dist/fastify/daemon-status.js.map +1 -0
  79. package/dist/fastify/host-tools.d.ts +13 -0
  80. package/dist/fastify/host-tools.d.ts.map +1 -0
  81. package/dist/fastify/host-tools.js +8 -0
  82. package/dist/fastify/host-tools.js.map +1 -0
  83. package/dist/fastify/index.d.ts +36 -0
  84. package/dist/fastify/index.d.ts.map +1 -0
  85. package/dist/fastify/index.js +18 -0
  86. package/dist/fastify/index.js.map +1 -0
  87. package/dist/fastify/local-daemon-request.d.ts +43 -0
  88. package/dist/fastify/local-daemon-request.d.ts.map +1 -0
  89. package/dist/fastify/local-daemon-request.js +155 -0
  90. package/dist/fastify/local-daemon-request.js.map +1 -0
  91. package/dist/fastify/origin.d.ts +21 -0
  92. package/dist/fastify/origin.d.ts.map +1 -0
  93. package/dist/fastify/origin.js +14 -0
  94. package/dist/fastify/origin.js.map +1 -0
  95. package/dist/fastify/request.d.ts +20 -0
  96. package/dist/fastify/request.d.ts.map +1 -0
  97. package/dist/fastify/request.js +25 -0
  98. package/dist/fastify/request.js.map +1 -0
  99. package/dist/fastify/response.d.ts +20 -0
  100. package/dist/fastify/response.d.ts.map +1 -0
  101. package/dist/fastify/response.js +41 -0
  102. package/dist/fastify/response.js.map +1 -0
  103. package/dist/fastify/route-registration-guard.d.ts +70 -0
  104. package/dist/fastify/route-registration-guard.d.ts.map +1 -0
  105. package/dist/fastify/route-registration-guard.js +69 -0
  106. package/dist/fastify/route-registration-guard.js.map +1 -0
  107. package/dist/fastify/run-stream.d.ts +18 -0
  108. package/dist/fastify/run-stream.d.ts.map +1 -0
  109. package/dist/fastify/run-stream.js +10 -0
  110. package/dist/fastify/run-stream.js.map +1 -0
  111. package/dist/fastify/runs.d.ts +17 -0
  112. package/dist/fastify/runs.d.ts.map +1 -0
  113. package/dist/fastify/runs.js +33 -0
  114. package/dist/fastify/runs.js.map +1 -0
  115. package/dist/frontend-control.d.ts +131 -0
  116. package/dist/frontend-control.d.ts.map +1 -0
  117. package/dist/frontend-control.js +77 -0
  118. package/dist/frontend-control.js.map +1 -0
  119. package/dist/frontend-sessions.d.ts +100 -0
  120. package/dist/frontend-sessions.d.ts.map +1 -0
  121. package/dist/frontend-sessions.js +180 -0
  122. package/dist/frontend-sessions.js.map +1 -0
  123. package/dist/health.d.ts +91 -0
  124. package/dist/health.d.ts.map +1 -0
  125. package/dist/health.js +53 -0
  126. package/dist/health.js.map +1 -0
  127. package/dist/host-tools.d.ts +174 -0
  128. package/dist/host-tools.d.ts.map +1 -0
  129. package/dist/host-tools.js +338 -0
  130. package/dist/host-tools.js.map +1 -0
  131. package/dist/index.d.ts +86 -0
  132. package/dist/index.d.ts.map +1 -0
  133. package/dist/index.js +40 -0
  134. package/dist/index.js.map +1 -0
  135. package/dist/local-daemon-request.d.ts +44 -0
  136. package/dist/local-daemon-request.d.ts.map +1 -0
  137. package/dist/local-daemon-request.js +145 -0
  138. package/dist/local-daemon-request.js.map +1 -0
  139. package/dist/media.d.ts +131 -0
  140. package/dist/media.d.ts.map +1 -0
  141. package/dist/media.js +352 -0
  142. package/dist/media.js.map +1 -0
  143. package/dist/memory.d.ts +230 -0
  144. package/dist/memory.d.ts.map +1 -0
  145. package/dist/memory.js +331 -0
  146. package/dist/memory.js.map +1 -0
  147. package/dist/model-proxy.d.ts +45 -0
  148. package/dist/model-proxy.d.ts.map +1 -0
  149. package/dist/model-proxy.js +619 -0
  150. package/dist/model-proxy.js.map +1 -0
  151. package/dist/origin-validation.d.ts +64 -0
  152. package/dist/origin-validation.d.ts.map +1 -0
  153. package/dist/origin-validation.js +226 -0
  154. package/dist/origin-validation.js.map +1 -0
  155. package/dist/origin.d.ts +32 -0
  156. package/dist/origin.d.ts.map +1 -0
  157. package/dist/origin.js +14 -0
  158. package/dist/origin.js.map +1 -0
  159. package/dist/pack-http.d.ts +30 -0
  160. package/dist/pack-http.d.ts.map +1 -0
  161. package/dist/pack-http.js +12 -0
  162. package/dist/pack-http.js.map +1 -0
  163. package/dist/raw-sse.d.ts +44 -0
  164. package/dist/raw-sse.d.ts.map +1 -0
  165. package/dist/raw-sse.js +93 -0
  166. package/dist/raw-sse.js.map +1 -0
  167. package/dist/remote-run-events.d.ts +66 -0
  168. package/dist/remote-run-events.d.ts.map +1 -0
  169. package/dist/remote-run-events.js +230 -0
  170. package/dist/remote-run-events.js.map +1 -0
  171. package/dist/request.d.ts +19 -0
  172. package/dist/request.d.ts.map +1 -0
  173. package/dist/request.js +25 -0
  174. package/dist/request.js.map +1 -0
  175. package/dist/research.d.ts +42 -0
  176. package/dist/research.d.ts.map +1 -0
  177. package/dist/research.js +316 -0
  178. package/dist/research.js.map +1 -0
  179. package/dist/response.d.ts +14 -0
  180. package/dist/response.d.ts.map +1 -0
  181. package/dist/response.js +45 -0
  182. package/dist/response.js.map +1 -0
  183. package/dist/route-manifest.d.ts +31 -0
  184. package/dist/route-manifest.d.ts.map +1 -0
  185. package/dist/route-manifest.js +103 -0
  186. package/dist/route-manifest.js.map +1 -0
  187. package/dist/route-registration-guard.d.ts +65 -0
  188. package/dist/route-registration-guard.d.ts.map +1 -0
  189. package/dist/route-registration-guard.js +71 -0
  190. package/dist/route-registration-guard.js.map +1 -0
  191. package/dist/routines.d.ts +114 -0
  192. package/dist/routines.d.ts.map +1 -0
  193. package/dist/routines.js +320 -0
  194. package/dist/routines.js.map +1 -0
  195. package/dist/run-stream.d.ts +60 -0
  196. package/dist/run-stream.d.ts.map +1 -0
  197. package/dist/run-stream.js +108 -0
  198. package/dist/run-stream.js.map +1 -0
  199. package/dist/runs.d.ts +79 -0
  200. package/dist/runs.d.ts.map +1 -0
  201. package/dist/runs.js +232 -0
  202. package/dist/runs.js.map +1 -0
  203. package/dist/sse.d.ts +139 -0
  204. package/dist/sse.d.ts.map +1 -0
  205. package/dist/sse.js +157 -0
  206. package/dist/sse.js.map +1 -0
  207. package/dist/terminals.d.ts +125 -0
  208. package/dist/terminals.d.ts.map +1 -0
  209. package/dist/terminals.js +349 -0
  210. package/dist/terminals.js.map +1 -0
  211. package/dist/tool-catalog.d.ts +68 -0
  212. package/dist/tool-catalog.d.ts.map +1 -0
  213. package/dist/tool-catalog.js +68 -0
  214. package/dist/tool-catalog.js.map +1 -0
  215. package/dist/types.d.ts +58 -0
  216. package/dist/types.d.ts.map +1 -0
  217. package/dist/types.js +5 -0
  218. package/dist/types.js.map +1 -0
  219. package/dist/workspace-root.d.ts +69 -0
  220. package/dist/workspace-root.d.ts.map +1 -0
  221. package/dist/workspace-root.js +63 -0
  222. package/dist/workspace-root.js.map +1 -0
  223. package/dist/xai.d.ts +86 -0
  224. package/dist/xai.d.ts.map +1 -0
  225. package/dist/xai.js +668 -0
  226. package/dist/xai.js.map +1 -0
  227. package/package.json +58 -0
package/dist/sse.js ADDED
@@ -0,0 +1,157 @@
1
+ import { createApiErrorResponse } from '@jini-ai/protocol';
2
+ /**
3
+ * Cap on events queued for one client — covers both a pre-header replay
4
+ * burst and any backlog built up while `res.write` reports backpressure
5
+ * (`write() === false`, awaiting `'drain'`). A stalled or malicious client
6
+ * would otherwise let an unbounded producer grow this array without limit
7
+ * (SEC-006, carried forward from `runs.ts`'s original constant). Once
8
+ * exceeded, the connection is dropped rather than accepting unbounded
9
+ * memory growth.
10
+ */
11
+ export const DEFAULT_MAX_QUEUED_SSE_EVENTS = 1000;
12
+ /** Default wire format: `id: <cursor>\nevent: <kind>\ndata: <json>\n\n`, matching the SSE spec's field syntax. */
13
+ function defaultFormatEvent(event) {
14
+ return `id: ${event.opaqueCursor}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`;
15
+ }
16
+ /**
17
+ * Builds a bounded, backpressure-aware SSE channel over an Express `Response`.
18
+ * The channel does not know where events come from — a route wires its own
19
+ * event source (a `RunLifecycle.stream` subscription, an `EventEmitter`
20
+ * fan-in, a PTY's `onData`, …) to {@link SseChannel.enqueue} and lets the
21
+ * channel own delivery, ordering, and backpressure.
22
+ *
23
+ * The response's own `'close'` event (the client disconnecting) is observed
24
+ * from construction time, before {@link SseChannel.open} is ever called —
25
+ * this lets a caller `await` some asynchronous subscribe step (as `runs.ts`
26
+ * does against `RunLifecycle.stream`) and still detect "the client already
27
+ * disappeared while we were waiting" via {@link SseChannel.isClosed} once
28
+ * that await resolves, without the disconnect handler itself trying to
29
+ * re-end an already-gone response (see {@link SseChannel.end}'s doc for why
30
+ * that distinction doesn't actually need special-casing).
31
+ *
32
+ * @param res - The response to stream over, typed against the raw `node:http` `ServerResponse`
33
+ * rather than Express's own `Response` — Express's `Response` extends `ServerResponse` directly,
34
+ * so every existing Express caller still satisfies this signature unchanged. Never read from — only
35
+ * `write`/`statusCode`/`setHeader`/`flushHeaders`/`end`/`on('close'|'drain')` are used, all of
36
+ * which exist identically on the raw type (the one genuinely Express-specific call this function
37
+ * used to make, `res.status(200)`, is `res.statusCode = 200` here instead — the same assignment
38
+ * Express's own `.status()` performs internally).
39
+ * @param options - See {@link CreateSseChannelOptions}.
40
+ * @complexity `enqueue`/`open`/`end` are O(1) amortized; a full queue drain is O(events written).
41
+ * @overallScore 100/100
42
+ */
43
+ export function createSseChannel(res, options = {}) {
44
+ const maxQueuedEvents = options.maxQueuedEvents ?? DEFAULT_MAX_QUEUED_SSE_EVENTS;
45
+ const formatEvent = options.formatEvent ?? defaultFormatEvent;
46
+ const queue = [];
47
+ let flowing = false;
48
+ let writable = true;
49
+ let closed = false;
50
+ const closeCallbacks = [];
51
+ const markClosed = () => {
52
+ if (closed)
53
+ return;
54
+ closed = true;
55
+ // Copy first: a callback that itself calls `onClose` during this loop must not be invoked
56
+ // twice or mutate the array being iterated.
57
+ const callbacks = closeCallbacks.splice(0, closeCallbacks.length);
58
+ for (const callback of callbacks)
59
+ callback();
60
+ };
61
+ const end = () => {
62
+ markClosed();
63
+ if (!res.writableEnded)
64
+ res.end();
65
+ };
66
+ const pump = () => {
67
+ if (!flowing || closed)
68
+ return;
69
+ while (writable && queue.length > 0) {
70
+ const event = queue.shift();
71
+ let wroteOk;
72
+ try {
73
+ wroteOk = res.write(formatEvent(event));
74
+ }
75
+ catch (error) {
76
+ // A dead/broken transport must never throw back through whatever is producing events —
77
+ // stop this channel only.
78
+ options.onWriteError?.(error);
79
+ end();
80
+ return;
81
+ }
82
+ if (wroteOk === false)
83
+ writable = false;
84
+ if (options.isEndEvent?.(event)) {
85
+ end();
86
+ return;
87
+ }
88
+ }
89
+ };
90
+ const enqueue = (event) => {
91
+ if (closed)
92
+ return;
93
+ if (queue.length >= maxQueuedEvents) {
94
+ // Slow/stalled consumer — disconnect rather than grow memory without bound.
95
+ end();
96
+ return;
97
+ }
98
+ queue.push(event);
99
+ pump();
100
+ };
101
+ const open = () => {
102
+ if (closed || flowing)
103
+ return;
104
+ res.statusCode = 200;
105
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
106
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
107
+ res.setHeader('Connection', 'keep-alive');
108
+ res.flushHeaders();
109
+ flowing = true;
110
+ pump();
111
+ };
112
+ res.on('drain', () => {
113
+ writable = true;
114
+ pump();
115
+ });
116
+ // The client disconnecting only marks the channel closed — it must not itself call `res.end()`
117
+ // (see `end()`'s doc for why calling it a second time would be safe anyway, but a route that
118
+ // still wants to send a *non-SSE* response — e.g. a JSON error — after discovering a subscribe
119
+ // failed needs `isClosed()` to be observable without the response having been touched yet).
120
+ res.on('close', markClosed);
121
+ const onClose = (callback) => {
122
+ if (closed) {
123
+ callback();
124
+ return;
125
+ }
126
+ closeCallbacks.push(callback);
127
+ };
128
+ return {
129
+ enqueue,
130
+ open,
131
+ isClosed: () => closed,
132
+ end,
133
+ abandon: markClosed,
134
+ onClose,
135
+ };
136
+ }
137
+ /** Reads a reconnect cursor from the standard `Last-Event-ID` header, falling back to an `afterCursor` query parameter. Shared by any SSE route that supports reconnect replay (the header takes precedence since it is what browsers set automatically on `EventSource` reconnect). */
138
+ export function requestedAfterCursor(req) {
139
+ const header = req.get('last-event-id');
140
+ if (header && header.length > 0)
141
+ return header;
142
+ const query = req.query.afterCursor;
143
+ return typeof query === 'string' && query.length > 0 ? query : null;
144
+ }
145
+ /**
146
+ * Writes an `ApiError`, wrapped in the standard `{ error }` envelope, directly onto a raw
147
+ * `ServerResponse` — for the narrow window before an `SseChannel` has opened (so Express's own
148
+ * `res.json()` response wrapper isn't safe to use yet). Produces the identical
149
+ * `createApiErrorResponse(error)` envelope shape `response.ts`'s `sendApiError`/`sendJson` do —
150
+ * this just writes that same shape without going through Express's response helper.
151
+ */
152
+ export function sendRawApiError(res, status, error) {
153
+ res.statusCode = status;
154
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
155
+ res.end(JSON.stringify(createApiErrorResponse(error)));
156
+ }
157
+ //# sourceMappingURL=sse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,125 @@
1
+ import type { Express } from 'express';
2
+ import type { Principal } from '@jini-ai/core';
3
+ import type { TerminalSessionActionResult, TerminalSessionInfo, TerminalSessionManager } from '@jini-ai/daemon';
4
+ import type { ToolExecutor } from '@jini-ai/daemon';
5
+ import { type AdapterContext } from './adapter.js';
6
+ import { type WorkspaceRootResolver } from './workspace-root.js';
7
+ import { type Result } from './types.js';
8
+ /** Diagnostic detail for an internal-error response the public API deliberately does not disclose (SEC-005, matching `runs.ts`/`db-ops.ts`): a spawn failure (e.g. a missing/uncompiled native addon) can embed executable paths or host detail. */
9
+ export interface TerminalsInternalErrorContext {
10
+ readonly source: 'terminal-create';
11
+ readonly correlationId: string;
12
+ readonly error: unknown;
13
+ }
14
+ /** Everything this route pack needs from the host. */
15
+ export interface TerminalsHttpDeps {
16
+ readonly manager: TerminalSessionManager;
17
+ readonly toolExecutor: ToolExecutor;
18
+ /** The identity these routes act as — same fixed, host-supplied-value shape as `db-ops.ts`'s `DaemonDbHttpDeps.principal` (this transport has no session/identity subsystem of its own). */
19
+ readonly principal: Principal;
20
+ /** Resolves a `resourceRef` to a spawn working directory. Defaults to {@link denyAllWorkspaceRoots} — a host that never wires a real resolver gets a 404 on every create call, never a guessed path (see `workspace-root.ts`). */
21
+ readonly resolveRoot?: WorkspaceRootResolver;
22
+ /** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
23
+ readonly onInternalError?: (context: TerminalsInternalErrorContext) => void;
24
+ }
25
+ export interface TerminalCreateRequest {
26
+ readonly resourceRef: string;
27
+ readonly detail?: string;
28
+ readonly cols?: number;
29
+ readonly rows?: number;
30
+ readonly shell?: string;
31
+ }
32
+ /**
33
+ * `POST /api/terminals` — the one gated call (see module doc). Resolves
34
+ * `resourceRef` to a working directory the same way `host-tools.ts`'s
35
+ * open-in route does, then authorizes+spawns the session through
36
+ * `ToolExecutor.execute(..., 'terminal.create', ...)`.
37
+ */
38
+ export declare const terminalCreateRoute: import("./types.js").JsonRouteSpec<TerminalCreateRequest, TerminalSessionInfo, TerminalsHttpDeps>;
39
+ export interface TerminalListResponse {
40
+ readonly terminals: readonly TerminalSessionInfo[];
41
+ }
42
+ /** `GET /api/terminals` — sessions the calling principal owns, optionally narrowed by `resourceRef`. Never gated through `ToolExecutor` — a read scoped to the caller's own sessions, matching `runs.ts`'s `runListRoute` precedent. */
43
+ export declare const terminalListRoute: import("./types.js").JsonRouteSpec<{
44
+ resourceRef?: string;
45
+ }, TerminalListResponse, TerminalsHttpDeps>;
46
+ export interface TerminalActionResponse {
47
+ readonly ok: boolean;
48
+ readonly terminal?: TerminalSessionInfo;
49
+ }
50
+ /**
51
+ * Exported (not just internal) so the `result.session === null` branch — a real race
52
+ * (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
53
+ * killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
54
+ * `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
55
+ * `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
56
+ * through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
57
+ * precedent for the same "extract into a directly-testable pure function" shape.
58
+ */
59
+ export declare function actionResultToApiResult(result: TerminalSessionActionResult, notFoundMessage: string): Result<TerminalActionResponse>;
60
+ /** `POST /api/terminals/:id/stdin` — writes `data` to the session's pty. Ownership-checked by `deps.manager.write` directly, not re-gated through `ToolExecutor` (see module doc). */
61
+ export declare const terminalStdinRoute: import("./types.js").JsonRouteSpec<{
62
+ id: string;
63
+ data: string;
64
+ }, TerminalActionResponse, TerminalsHttpDeps>;
65
+ /** `POST /api/terminals/:id/resize` — resizes the session's pty. */
66
+ export declare const terminalResizeRoute: import("./types.js").JsonRouteSpec<{
67
+ id: string;
68
+ cols: number;
69
+ rows: number;
70
+ }, TerminalActionResponse, TerminalsHttpDeps>;
71
+ /** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
72
+ export declare const terminalKillRoute: import("./types.js").JsonRouteSpec<string, TerminalActionResponse, TerminalsHttpDeps>;
73
+ /** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
74
+ export declare const terminalDeleteRoute: import("./types.js").JsonRouteSpec<string, TerminalActionResponse, TerminalsHttpDeps>;
75
+ /** The one method {@link createDeferredEndGate} needs from `sse.ts`'s channel — narrowed so the gate is unit-testable against a bare fake, not a real `SseChannel`. */
76
+ interface DeferredEndChannel {
77
+ end(): void;
78
+ }
79
+ /**
80
+ * The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
81
+ * `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
82
+ * function's inline comment for *why* the deferral exists (an already-exited session's replay
83
+ * path can call `end()` before `open()` has ever run).
84
+ *
85
+ * **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
86
+ * ordering every *currently reachable* production call graph in this repo takes for a live
87
+ * session that exits while already streaming — but it is unreachable through that graph, not
88
+ * unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
89
+ * terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
90
+ * client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
91
+ * === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
92
+ * which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
93
+ * detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
94
+ * `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
95
+ * snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
96
+ * `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
97
+ * this repo's own `@jini-ai/platform` + `@jini-ai/http-kit` composition can currently produce (confirmed by
98
+ * instrumenting the real call path directly, not inferred from reading the source — a synchronous
99
+ * console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
100
+ * That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
101
+ * future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
102
+ * sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
103
+ * exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
104
+ * here rather than deleted for being unreachable *today* or left silently uncovered.
105
+ */
106
+ export declare function createDeferredEndGate(channel: DeferredEndChannel): {
107
+ markOpened(): void;
108
+ end(): void;
109
+ };
110
+ /**
111
+ * `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
112
+ * reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
113
+ * `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
114
+ * `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
115
+ * `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
116
+ *
117
+ * Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
118
+ * `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
119
+ * directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
120
+ */
121
+ export declare function registerTerminalEventStream(app: Express, deps: TerminalsHttpDeps, adapter: AdapterContext): void;
122
+ /** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
123
+ export declare function registerTerminalRoutes(app: Express, deps: TerminalsHttpDeps, adapter: AdapterContext): void;
124
+ export {};
125
+ //# sourceMappingURL=terminals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminals.d.ts","sourceRoot":"","sources":["../src/terminals.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,349 @@
1
+ /**
2
+ * @module terminals
3
+ *
4
+ * Interactive-terminal HTTP surface, mirroring OD's
5
+ * `apps/daemon/src/routes/terminal.ts` (`/api/projects/:id/terminals` +
6
+ * `.../:tid/{stream,stdin,resize,kill}`) generalized under `/api/terminals`.
7
+ * See `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
8
+ * for the design discussion and `@jini-ai/daemon`'s `terminal-session.ts` module
9
+ * doc for the session-ownership/gating decisions this route pack calls into.
10
+ *
11
+ * This file is deliberately thin — no PTY spawning, no session registry, no
12
+ * ownership logic lives here (that is `@jini-ai/daemon`'s `TerminalSessionManager`,
13
+ * injected as `deps.manager`). This package only:
14
+ *
15
+ * - Resolves a `POST /api/terminals` request's `resourceRef` to a spawn `cwd`
16
+ * via `workspace-root.ts` (the same port `host-tools.ts`'s open-in route
17
+ * uses), then routes creation through `deps.toolExecutor.execute(...,
18
+ * 'terminal.create', ...)` — the one call `@jini-ai/daemon`'s module gates by
19
+ * policy (matching `db-ops.ts`'s tool-execution-boundary precedent).
20
+ * - Routes `stdin`/`resize`/`kill`/`stream` directly to `deps.manager`'s
21
+ * lighter, session-ownership-checked methods — deliberately **not** through
22
+ * `ToolExecutor` again (see the daemon module's doc for why a full
23
+ * authorize/confirm round-trip per keystroke would make a terminal
24
+ * unusable).
25
+ * - Adapts `deps.manager.attach`'s push/end sink to `sse.ts`'s generic SSE
26
+ * channel, the same way `runs.ts` adapts `RunLifecycle.stream`.
27
+ */
28
+ import { randomUUID } from 'node:crypto';
29
+ import { TERMINAL_CREATE_TOOL_ID } from '@jini-ai/daemon';
30
+ import { createApiError } from '@jini-ai/protocol';
31
+ import { defineJsonRoute, mountJsonRoute } from './adapter.js';
32
+ import { guardSameOrigin } from './origin.js';
33
+ import { validationError } from './request.js';
34
+ import { sendApiError, statusForError } from './response.js';
35
+ import { createSseChannel, requestedAfterCursor } from './sse.js';
36
+ import { denyAllWorkspaceRoots, resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
37
+ import { err, ok } from './types.js';
38
+ function defaultTerminalsInternalErrorSink(context) {
39
+ // eslint-disable-next-line no-console
40
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
41
+ }
42
+ function isRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
45
+ /** Every one-off `terminal.create` tool call gets its own opaque `RunRef` — matching `db-ops.ts`'s `freshRun()`: these are single requests, not steps of a longer-lived agent run. */
46
+ function freshRun() {
47
+ return { id: randomUUID() };
48
+ }
49
+ function toolResultToApiResult(deps, result) {
50
+ switch (result.status) {
51
+ case 'completed':
52
+ return ok(result.output);
53
+ case 'denied':
54
+ return err(createApiError('TOOL_OPERATION_DENIED', 'this operation was denied by policy'));
55
+ case 'confirmation-denied':
56
+ return err(createApiError('TOOL_OPERATION_DENIED', 'this operation was denied during confirmation'));
57
+ case 'timed-out':
58
+ case 'cancelled':
59
+ case 'failed': {
60
+ const correlationId = randomUUID();
61
+ const sink = deps.onInternalError ?? defaultTerminalsInternalErrorSink;
62
+ sink({ source: 'terminal-create', correlationId, error: result.status === 'failed' ? result.error : result.status });
63
+ return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
64
+ }
65
+ }
66
+ }
67
+ function parseTerminalCreate(input) {
68
+ if (!isRecord(input.body))
69
+ return err(validationError('body must be a JSON object'));
70
+ const resourceRef = input.body.resourceRef;
71
+ if (typeof resourceRef !== 'string' || resourceRef.length === 0) {
72
+ return err(validationError('resourceRef is required', [{ path: 'resourceRef', message: 'required non-empty string' }]));
73
+ }
74
+ const detail = input.body.detail;
75
+ if (detail !== undefined && (typeof detail !== 'string' || detail.length === 0)) {
76
+ return err(validationError('detail must be a non-empty string when provided'));
77
+ }
78
+ const cols = input.body.cols;
79
+ if (cols !== undefined && typeof cols !== 'number') {
80
+ return err(validationError('cols must be a number when provided'));
81
+ }
82
+ const rows = input.body.rows;
83
+ if (rows !== undefined && typeof rows !== 'number') {
84
+ return err(validationError('rows must be a number when provided'));
85
+ }
86
+ const shell = input.body.shell;
87
+ if (shell !== undefined && (typeof shell !== 'string' || shell.length === 0)) {
88
+ return err(validationError('shell must be a non-empty string when provided'));
89
+ }
90
+ return ok({
91
+ resourceRef,
92
+ ...(detail === undefined ? {} : { detail }),
93
+ ...(cols === undefined ? {} : { cols }),
94
+ ...(rows === undefined ? {} : { rows }),
95
+ ...(shell === undefined ? {} : { shell }),
96
+ });
97
+ }
98
+ /**
99
+ * `POST /api/terminals` — the one gated call (see module doc). Resolves
100
+ * `resourceRef` to a working directory the same way `host-tools.ts`'s
101
+ * open-in route does, then authorizes+spawns the session through
102
+ * `ToolExecutor.execute(..., 'terminal.create', ...)`.
103
+ */
104
+ export const terminalCreateRoute = defineJsonRoute({
105
+ method: 'post',
106
+ path: '/api/terminals',
107
+ requireSameOrigin: true,
108
+ parse: parseTerminalCreate,
109
+ handle: async (input, deps) => {
110
+ let cwd;
111
+ try {
112
+ cwd = await resolveWorkspaceRoot({ resourceRef: input.resourceRef, ...(input.detail === undefined ? {} : { detail: input.detail }) }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
113
+ }
114
+ catch (error) {
115
+ if (error instanceof WorkspaceRootDeniedError) {
116
+ return err(createApiError('NOT_FOUND', `resource "${input.resourceRef}" was not found`));
117
+ }
118
+ throw error;
119
+ }
120
+ const toolInput = {
121
+ resourceRef: input.resourceRef,
122
+ cwd,
123
+ ...(input.cols === undefined ? {} : { cols: input.cols }),
124
+ ...(input.rows === undefined ? {} : { rows: input.rows }),
125
+ ...(input.shell === undefined ? {} : { shell: input.shell }),
126
+ };
127
+ const result = await deps.toolExecutor.execute(deps.principal, freshRun(), TERMINAL_CREATE_TOOL_ID, toolInput);
128
+ return toolResultToApiResult(deps, result);
129
+ },
130
+ successStatus: 201,
131
+ });
132
+ function parseTerminalList(input) {
133
+ const value = input.query.resourceRef;
134
+ if (value === undefined)
135
+ return ok({});
136
+ if (typeof value !== 'string' || value.length === 0) {
137
+ return err(validationError('resourceRef must be a non-empty string when provided'));
138
+ }
139
+ return ok({ resourceRef: value });
140
+ }
141
+ /** `GET /api/terminals` — sessions the calling principal owns, optionally narrowed by `resourceRef`. Never gated through `ToolExecutor` — a read scoped to the caller's own sessions, matching `runs.ts`'s `runListRoute` precedent. */
142
+ export const terminalListRoute = defineJsonRoute({
143
+ method: 'get',
144
+ path: '/api/terminals',
145
+ parse: parseTerminalList,
146
+ handle: (input, deps) => ok({ terminals: deps.manager.list(deps.principal, input.resourceRef === undefined ? {} : { resourceRef: input.resourceRef }) }),
147
+ });
148
+ function parseTerminalId(input) {
149
+ const id = input.params.id;
150
+ return typeof id === 'string' && id.length > 0 ? ok(id) : err(validationError('id must be a non-empty path parameter'));
151
+ }
152
+ /**
153
+ * Exported (not just internal) so the `result.session === null` branch — a real race
154
+ * (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
155
+ * killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
156
+ * `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
157
+ * `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
158
+ * through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
159
+ * precedent for the same "extract into a directly-testable pure function" shape.
160
+ */
161
+ export function actionResultToApiResult(result, notFoundMessage) {
162
+ if (result.status === 'not-found')
163
+ return err(createApiError('NOT_FOUND', notFoundMessage));
164
+ return ok({ ok: result.ok, ...(result.session ? { terminal: result.session } : {}) });
165
+ }
166
+ function parseStdinInput(input) {
167
+ const parsedId = parseTerminalId(input);
168
+ if (!parsedId.ok)
169
+ return parsedId;
170
+ if (!isRecord(input.body))
171
+ return err(validationError('body must be a JSON object'));
172
+ const data = input.body.data;
173
+ if (typeof data !== 'string') {
174
+ return err(validationError('data (string) is required', [{ path: 'data', message: 'required string' }]));
175
+ }
176
+ return ok({ id: parsedId.value, data });
177
+ }
178
+ /** `POST /api/terminals/:id/stdin` — writes `data` to the session's pty. Ownership-checked by `deps.manager.write` directly, not re-gated through `ToolExecutor` (see module doc). */
179
+ export const terminalStdinRoute = defineJsonRoute({
180
+ method: 'post',
181
+ path: '/api/terminals/:id/stdin',
182
+ requireSameOrigin: true,
183
+ parse: parseStdinInput,
184
+ handle: async (input, deps) => {
185
+ const result = await deps.manager.write(deps.principal, input.id, input.data);
186
+ return actionResultToApiResult(result, `terminal "${input.id}" was not found`);
187
+ },
188
+ });
189
+ function parseResizeInput(input) {
190
+ const parsedId = parseTerminalId(input);
191
+ if (!parsedId.ok)
192
+ return parsedId;
193
+ if (!isRecord(input.body))
194
+ return err(validationError('body must be a JSON object'));
195
+ const cols = Number(input.body.cols);
196
+ const rows = Number(input.body.rows);
197
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
198
+ return err(validationError('cols and rows (numbers) are required'));
199
+ }
200
+ return ok({ id: parsedId.value, cols, rows });
201
+ }
202
+ /** `POST /api/terminals/:id/resize` — resizes the session's pty. */
203
+ export const terminalResizeRoute = defineJsonRoute({
204
+ method: 'post',
205
+ path: '/api/terminals/:id/resize',
206
+ requireSameOrigin: true,
207
+ parse: parseResizeInput,
208
+ handle: async (input, deps) => {
209
+ const result = await deps.manager.resize(deps.principal, input.id, input.cols, input.rows);
210
+ return actionResultToApiResult(result, `terminal "${input.id}" was not found`);
211
+ },
212
+ });
213
+ function handleKill(id, deps) {
214
+ return deps.manager.kill(deps.principal, id, 'SIGTERM').then((result) => actionResultToApiResult(result, `terminal "${id}" was not found`));
215
+ }
216
+ /** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
217
+ export const terminalKillRoute = defineJsonRoute({
218
+ method: 'post',
219
+ path: '/api/terminals/:id/kill',
220
+ requireSameOrigin: true,
221
+ parse: parseTerminalId,
222
+ handle: (id, deps) => handleKill(id, deps),
223
+ });
224
+ /** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
225
+ export const terminalDeleteRoute = defineJsonRoute({
226
+ method: 'delete',
227
+ path: '/api/terminals/:id',
228
+ requireSameOrigin: true,
229
+ parse: parseTerminalId,
230
+ handle: (id, deps) => handleKill(id, deps),
231
+ });
232
+ /**
233
+ * The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
234
+ * `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
235
+ * function's inline comment for *why* the deferral exists (an already-exited session's replay
236
+ * path can call `end()` before `open()` has ever run).
237
+ *
238
+ * **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
239
+ * ordering every *currently reachable* production call graph in this repo takes for a live
240
+ * session that exits while already streaming — but it is unreachable through that graph, not
241
+ * unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
242
+ * terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
243
+ * client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
244
+ * === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
245
+ * which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
246
+ * detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
247
+ * `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
248
+ * snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
249
+ * `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
250
+ * this repo's own `@jini-ai/platform` + `@jini-ai/http-kit` composition can currently produce (confirmed by
251
+ * instrumenting the real call path directly, not inferred from reading the source — a synchronous
252
+ * console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
253
+ * That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
254
+ * future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
255
+ * sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
256
+ * exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
257
+ * here rather than deleted for being unreachable *today* or left silently uncovered.
258
+ */
259
+ export function createDeferredEndGate(channel) {
260
+ let opened = false;
261
+ let endRequestedBeforeOpen = false;
262
+ return {
263
+ markOpened() {
264
+ opened = true;
265
+ // Idempotent-safe even if the queued 'exit' event already auto-closed the channel via
266
+ // `isEndEvent` during `open()`'s own drain (`channel.end()` is documented safe to call twice).
267
+ if (endRequestedBeforeOpen)
268
+ channel.end();
269
+ },
270
+ end() {
271
+ if (opened) {
272
+ channel.end();
273
+ }
274
+ else {
275
+ endRequestedBeforeOpen = true;
276
+ }
277
+ },
278
+ };
279
+ }
280
+ /**
281
+ * `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
282
+ * reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
283
+ * `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
284
+ * `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
285
+ * `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
286
+ *
287
+ * Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
288
+ * `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
289
+ * directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
290
+ */
291
+ export function registerTerminalEventStream(app, deps, adapter) {
292
+ app.get('/api/terminals/:id/stream', (req, res) => {
293
+ const origin = guardSameOrigin(req, adapter);
294
+ if (!origin.ok) {
295
+ sendApiError(res, statusForError(origin.error), origin.error);
296
+ return;
297
+ }
298
+ const id = req.params.id;
299
+ if (typeof id !== 'string' || id.length === 0) {
300
+ sendApiError(res, 400, createApiError('BAD_REQUEST', 'id must be a non-empty path parameter'));
301
+ return;
302
+ }
303
+ const cursor = requestedAfterCursor(req);
304
+ const lastEventId = cursor === null ? 0 : Number(cursor);
305
+ const channel = createSseChannel(res, { isEndEvent: (event) => event.kind === 'exit' });
306
+ // `deps.manager.attach` can call `sink.end()` synchronously, from inside the very call below
307
+ // (an already-exited session's replay path) — before the channel has ever been `open()`ed. If
308
+ // `end()` mapped straight to `channel.end()`, that would end the response with the queued
309
+ // backlog never flushed (headers never even sent). `createDeferredEndGate` defers the actual
310
+ // `channel.end()` call until after `open()` has drained whatever `send()` already queued — see
311
+ // that function's own doc for the full reachability analysis of its other branch.
312
+ const deferredEnd = createDeferredEndGate(channel);
313
+ const sink = {
314
+ send(event, data, eventId) {
315
+ channel.enqueue({ opaqueCursor: String(eventId), kind: event, data });
316
+ },
317
+ end() {
318
+ deferredEnd.end();
319
+ },
320
+ };
321
+ let attachedSink = null;
322
+ channel.onClose(() => {
323
+ if (attachedSink)
324
+ deps.manager.detach(id, attachedSink);
325
+ });
326
+ const result = deps.manager.attach(deps.principal, id, lastEventId, sink);
327
+ if (result === 'not-found') {
328
+ channel.abandon();
329
+ sendApiError(res, 404, createApiError('NOT_FOUND', `terminal "${id}" was not found`));
330
+ return;
331
+ }
332
+ if (result === 'attached') {
333
+ attachedSink = sink;
334
+ }
335
+ channel.open();
336
+ deferredEnd.markOpened();
337
+ });
338
+ }
339
+ /** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
340
+ export function registerTerminalRoutes(app, deps, adapter) {
341
+ mountJsonRoute(app, terminalListRoute, deps, adapter);
342
+ mountJsonRoute(app, terminalCreateRoute, deps, adapter);
343
+ mountJsonRoute(app, terminalStdinRoute, deps, adapter);
344
+ mountJsonRoute(app, terminalResizeRoute, deps, adapter);
345
+ mountJsonRoute(app, terminalKillRoute, deps, adapter);
346
+ mountJsonRoute(app, terminalDeleteRoute, deps, adapter);
347
+ registerTerminalEventStream(app, deps, adapter);
348
+ }
349
+ //# sourceMappingURL=terminals.js.map