@jini-ai/daemon 0.3.8 → 0.5.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 (159) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +50 -8
  3. package/dist/agent-executor/contracts.d.ts +736 -0
  4. package/dist/agent-executor/contracts.js +28 -0
  5. package/dist/agent-executor/event-translation.d.ts +87 -0
  6. package/dist/agent-executor/event-translation.js +230 -0
  7. package/dist/agent-executor/events.d.ts +190 -0
  8. package/dist/agent-executor/events.js +848 -0
  9. package/dist/agent-executor/handoff.d.ts +18 -0
  10. package/dist/agent-executor/handoff.js +16 -0
  11. package/dist/agent-executor/index.d.ts +98 -0
  12. package/dist/agent-executor/index.js +98 -0
  13. package/dist/agent-executor/launch.d.ts +375 -0
  14. package/dist/agent-executor/launch.js +1001 -0
  15. package/dist/agent-executor/mcp-bridge.d.ts +338 -0
  16. package/dist/agent-executor/mcp-bridge.js +835 -0
  17. package/dist/agent-executor/values.d.ts +5 -0
  18. package/dist/agent-executor/values.js +13 -0
  19. package/dist/agent-executor.d.ts +375 -176
  20. package/dist/agent-executor.js +388 -335
  21. package/dist/close-status.d.ts +11 -8
  22. package/dist/close-status.js +31 -19
  23. package/dist/continuation/continuation-transport.d.ts +3 -1
  24. package/dist/continuation/continuation-transport.js +1 -1
  25. package/dist/continuation/journal.d.ts +12 -5
  26. package/dist/continuation/journal.js +4 -4
  27. package/dist/continuation/run-scoped-context-store.d.ts +13 -4
  28. package/dist/continuation/run-scoped-context-store.js +6 -6
  29. package/dist/continuation/run-start-handler.d.ts +1 -1
  30. package/dist/continuation/run-start-handler.js +3 -16
  31. package/dist/delegated-tool-bridge.d.ts +7 -3
  32. package/dist/delegated-tool-bridge.js +32 -31
  33. package/dist/event-log.d.ts +7 -5
  34. package/dist/event-log.js +12 -11
  35. package/dist/frontend-capability-tools.d.ts +1 -1
  36. package/dist/frontend-capability-tools.js +3 -2
  37. package/dist/frontend-session-registry.d.ts +32 -9
  38. package/dist/frontend-session-registry.js +12 -11
  39. package/dist/http/active-context.d.ts +105 -0
  40. package/dist/http/active-context.js +67 -0
  41. package/dist/http/agents.d.ts +68 -0
  42. package/dist/http/agents.js +12 -0
  43. package/dist/http/attachments.d.ts +618 -0
  44. package/dist/http/attachments.js +1034 -0
  45. package/dist/http/cancel-owned-runs.d.ts +49 -0
  46. package/dist/http/cancel-owned-runs.js +35 -0
  47. package/dist/http/component-catalog.d.ts +84 -0
  48. package/dist/http/component-catalog.js +64 -0
  49. package/dist/http/connectors.d.ts +209 -0
  50. package/dist/http/connectors.js +409 -0
  51. package/dist/http/daemon-status.d.ts +76 -0
  52. package/dist/http/daemon-status.js +55 -0
  53. package/dist/http/db-ops.d.ts +123 -0
  54. package/dist/http/db-ops.js +189 -0
  55. package/dist/http/delegated-tools.d.ts +113 -0
  56. package/dist/http/delegated-tools.js +230 -0
  57. package/dist/http/frontend-control.d.ts +136 -0
  58. package/dist/http/frontend-control.js +79 -0
  59. package/dist/http/frontend-sessions.d.ts +110 -0
  60. package/dist/http/frontend-sessions.js +180 -0
  61. package/dist/http/health.d.ts +95 -0
  62. package/dist/http/health.js +40 -0
  63. package/dist/http/host-tools.d.ts +214 -0
  64. package/dist/http/host-tools.js +329 -0
  65. package/dist/http/index.d.ts +52 -0
  66. package/dist/http/index.js +27 -0
  67. package/dist/http/media.d.ts +146 -0
  68. package/dist/http/media.js +346 -0
  69. package/dist/http/memory.d.ts +265 -0
  70. package/dist/http/memory.js +328 -0
  71. package/dist/http/model-proxy.d.ts +49 -0
  72. package/dist/http/model-proxy.js +642 -0
  73. package/dist/http/remote-run-events.d.ts +70 -0
  74. package/dist/http/remote-run-events.js +224 -0
  75. package/dist/http/research.d.ts +48 -0
  76. package/dist/http/research.js +315 -0
  77. package/dist/http/route-manifest.d.ts +35 -0
  78. package/dist/http/route-manifest.js +103 -0
  79. package/dist/http/routines.d.ts +120 -0
  80. package/dist/http/routines.js +298 -0
  81. package/dist/http/run-credentials.d.ts +73 -0
  82. package/dist/http/run-credentials.js +71 -0
  83. package/dist/http/runs.d.ts +91 -0
  84. package/dist/http/runs.js +221 -0
  85. package/dist/http/terminals.d.ts +138 -0
  86. package/dist/http/terminals.js +325 -0
  87. package/dist/http/tool-catalog.d.ts +78 -0
  88. package/dist/http/tool-catalog.js +66 -0
  89. package/dist/http/workspace-root.d.ts +77 -0
  90. package/dist/http/workspace-root.js +63 -0
  91. package/dist/http/xai.d.ts +90 -0
  92. package/dist/http/xai.js +655 -0
  93. package/dist/image-prompt-delivery.d.ts +13 -3
  94. package/dist/image-prompt-delivery.js +5 -5
  95. package/dist/index.d.ts +556 -4
  96. package/dist/index.js +556 -4
  97. package/dist/legacy-data-migration.d.ts +59 -8
  98. package/dist/legacy-data-migration.js +38 -33
  99. package/dist/read-only-tools.d.ts +130 -0
  100. package/dist/read-only-tools.js +115 -0
  101. package/dist/remote-tool-bridge.d.ts +11 -5
  102. package/dist/remote-tool-bridge.js +15 -15
  103. package/dist/routines/index.d.ts +1 -1
  104. package/dist/routines/index.js +1 -1
  105. package/dist/routines/routine-store.d.ts +34 -12
  106. package/dist/routines/routine-store.js +21 -20
  107. package/dist/routines/schedule.d.ts +48 -10
  108. package/dist/routines/schedule.js +21 -21
  109. package/dist/routines/scheduler.d.ts +32 -11
  110. package/dist/routines/scheduler.js +61 -57
  111. package/dist/routines/types.d.ts +10 -3
  112. package/dist/run/core/result.d.ts +6 -2
  113. package/dist/run/core/result.js +3 -3
  114. package/dist/run/core/retry.d.ts +17 -4
  115. package/dist/run/core/retry.js +7 -11
  116. package/dist/run/diagnostics/diagnostics.d.ts +14 -4
  117. package/dist/run/diagnostics/diagnostics.js +7 -7
  118. package/dist/run/index.d.ts +1 -1
  119. package/dist/run/index.js +1 -1
  120. package/dist/run-credentials.d.ts +138 -0
  121. package/dist/run-credentials.js +152 -0
  122. package/dist/run-lifecycle.d.ts +92 -33
  123. package/dist/run-lifecycle.js +81 -74
  124. package/dist/scheduler.d.ts +8 -0
  125. package/dist/scheduler.js +2 -0
  126. package/dist/session-coordination.d.ts +115 -0
  127. package/dist/session-coordination.js +111 -0
  128. package/dist/store/agent-sessions/errors.d.ts +8 -0
  129. package/dist/store/agent-sessions/errors.js +10 -0
  130. package/dist/store/agent-sessions/index.d.ts +12 -0
  131. package/dist/store/agent-sessions/index.js +19 -0
  132. package/dist/store/agent-sessions/pglite.d.ts +12 -0
  133. package/dist/store/agent-sessions/pglite.js +7 -0
  134. package/dist/store/agent-sessions/ports.d.ts +24 -0
  135. package/dist/store/agent-sessions/ports.js +2 -0
  136. package/dist/store/agent-sessions/postgres.d.ts +12 -0
  137. package/dist/store/agent-sessions/postgres.js +7 -0
  138. package/dist/store/agent-sessions/schema.d.ts +3 -0
  139. package/dist/store/agent-sessions/schema.js +23 -0
  140. package/dist/store/agent-sessions/sql.d.ts +35 -0
  141. package/dist/store/agent-sessions/sql.js +59 -0
  142. package/dist/store/agent-sessions/sqlite.d.ts +117 -0
  143. package/dist/store/agent-sessions/sqlite.js +128 -0
  144. package/dist/store/event-log/sqlite.d.ts +75 -0
  145. package/dist/store/event-log/sqlite.js +230 -0
  146. package/dist/surface-exchanges.d.ts +97 -0
  147. package/dist/surface-exchanges.js +153 -0
  148. package/dist/terminal-session.d.ts +43 -11
  149. package/dist/terminal-session.js +22 -20
  150. package/dist/tokens.js +4 -4
  151. package/dist/tool-audit.d.ts +114 -0
  152. package/dist/tool-audit.js +95 -0
  153. package/dist/tool-executor.d.ts +22 -7
  154. package/dist/tool-executor.js +16 -16
  155. package/dist/tool-result-media.d.ts +3 -1
  156. package/dist/tool-result-media.js +1 -1
  157. package/dist/tool-result-surfaces.d.ts +3 -1
  158. package/dist/tool-result-surfaces.js +1 -1
  159. package/package.json +127 -9
@@ -0,0 +1,325 @@
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 '../index.js';
30
+ import { createApiError } from '@jini-ai/protocol';
31
+ import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
32
+ import { guardSameOrigin } from '@jini-ai/http-kit';
33
+ import { validationError } from '@jini-ai/http-kit';
34
+ import { sendApiError, statusForError } from '@jini-ai/http-kit';
35
+ import { createSseChannel, requestedAfterCursor } from '@jini-ai/http-kit';
36
+ import { denyAllWorkspaceRoots, resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
37
+ import { err, ok } from '@jini-ai/http-kit';
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({ value: result.output });
53
+ case 'denied':
54
+ return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied by policy' }) });
55
+ case 'confirmation-denied':
56
+ return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: '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({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
64
+ }
65
+ }
66
+ }
67
+ function parseTerminalCreate(input) {
68
+ if (!isRecord(input.body))
69
+ return err({ error: validationError({ message: 'body must be a JSON object' }) });
70
+ const resourceRef = input.body.resourceRef;
71
+ if (typeof resourceRef !== 'string' || resourceRef.length === 0) {
72
+ return err({ error: validationError({ message: 'resourceRef is required' }, { issues: [{ 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({ error: validationError({ message: '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({ error: validationError({ message: 'cols must be a number when provided' }) });
81
+ }
82
+ const rows = input.body.rows;
83
+ if (rows !== undefined && typeof rows !== 'number') {
84
+ return err({ error: validationError({ message: '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({ error: validationError({ message: 'shell must be a non-empty string when provided' }) });
89
+ }
90
+ return ok({
91
+ value: {
92
+ resourceRef,
93
+ ...(detail === undefined ? {} : { detail }),
94
+ ...(cols === undefined ? {} : { cols }),
95
+ ...(rows === undefined ? {} : { rows }),
96
+ ...(shell === undefined ? {} : { shell }),
97
+ }
98
+ });
99
+ }
100
+ /**
101
+ * `POST /api/terminals` — the one gated call (see module doc). Resolves
102
+ * `resourceRef` to a working directory the same way `host-tools.ts`'s
103
+ * open-in route does, then authorizes+spawns the session through
104
+ * `ToolExecutor.execute(..., 'terminal.create', ...)`.
105
+ */
106
+ export const terminalCreateRoute = defineJsonRoute({
107
+ method: 'post', path: '/api/terminals', parse: parseTerminalCreate, handle: async ({ input, deps }) => {
108
+ let cwd;
109
+ try {
110
+ cwd = await resolveWorkspaceRoot({ request: { resourceRef: input.resourceRef, ...(input.detail === undefined ? {} : { detail: input.detail }) } }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
111
+ }
112
+ catch (error) {
113
+ if (error instanceof WorkspaceRootDeniedError) {
114
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: `resource "${input.resourceRef}" was not found` }) });
115
+ }
116
+ throw error;
117
+ }
118
+ const toolInput = {
119
+ resourceRef: input.resourceRef,
120
+ cwd,
121
+ ...(input.cols === undefined ? {} : { cols: input.cols }),
122
+ ...(input.rows === undefined ? {} : { rows: input.rows }),
123
+ ...(input.shell === undefined ? {} : { shell: input.shell }),
124
+ };
125
+ const result = await deps.toolExecutor.execute({ principal: deps.principal, run: freshRun(), toolId: TERMINAL_CREATE_TOOL_ID, input: toolInput });
126
+ return toolResultToApiResult(deps, result);
127
+ }
128
+ }, { requireSameOrigin: true, successStatus: 201 });
129
+ function parseTerminalList(input) {
130
+ const value = input.query.resourceRef;
131
+ if (value === undefined)
132
+ return ok({ value: {} });
133
+ if (typeof value !== 'string' || value.length === 0) {
134
+ return err({ error: validationError({ message: 'resourceRef must be a non-empty string when provided' }) });
135
+ }
136
+ return ok({ value: { resourceRef: value } });
137
+ }
138
+ /** `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. */
139
+ export const terminalListRoute = defineJsonRoute({
140
+ method: 'get', path: '/api/terminals', parse: parseTerminalList, handle: ({ input, deps }) => ok({ value: { terminals: deps.manager.list({ principal: deps.principal }, { filter: input.resourceRef === undefined ? {} : { resourceRef: input.resourceRef } }) } })
141
+ });
142
+ function parseTerminalId(input) {
143
+ const id = input.params.id;
144
+ return typeof id === 'string' && id.length > 0 ? ok({ value: id }) : err({ error: validationError({ message: 'id must be a non-empty path parameter' }) });
145
+ }
146
+ /**
147
+ * Exported (not just internal) so the `result.session === null` branch — a real race
148
+ * (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
149
+ * killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
150
+ * `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
151
+ * `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
152
+ * through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
153
+ * precedent for the same "extract into a directly-testable pure function" shape.
154
+ */
155
+ export function actionResultToApiResult({ result, notFoundMessage }, _optional = {}) {
156
+ if (result.status === 'not-found')
157
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: notFoundMessage }) });
158
+ return ok({ value: { ok: result.ok, ...(result.session ? { terminal: result.session } : {}) } });
159
+ }
160
+ function parseStdinInput(input) {
161
+ const parsedId = parseTerminalId(input);
162
+ if (!parsedId.ok)
163
+ return parsedId;
164
+ if (!isRecord(input.body))
165
+ return err({ error: validationError({ message: 'body must be a JSON object' }) });
166
+ const data = input.body.data;
167
+ if (typeof data !== 'string') {
168
+ return err({ error: validationError({ message: 'data (string) is required' }, { issues: [{ path: 'data', message: 'required string' }] }) });
169
+ }
170
+ return ok({ value: { id: parsedId.value, data } });
171
+ }
172
+ /** `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). */
173
+ export const terminalStdinRoute = defineJsonRoute({
174
+ method: 'post', path: '/api/terminals/:id/stdin', parse: parseStdinInput, handle: async ({ input, deps }) => {
175
+ const result = await deps.manager.write({ principal: deps.principal, id: input.id, input: input.data });
176
+ return actionResultToApiResult({ result, notFoundMessage: `terminal "${input.id}" was not found` });
177
+ }
178
+ }, { requireSameOrigin: true });
179
+ function parseResizeInput(input) {
180
+ const parsedId = parseTerminalId(input);
181
+ if (!parsedId.ok)
182
+ return parsedId;
183
+ if (!isRecord(input.body))
184
+ return err({ error: validationError({ message: 'body must be a JSON object' }) });
185
+ const cols = Number(input.body.cols);
186
+ const rows = Number(input.body.rows);
187
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
188
+ return err({ error: validationError({ message: 'cols and rows (numbers) are required' }) });
189
+ }
190
+ return ok({ value: { id: parsedId.value, cols, rows } });
191
+ }
192
+ /** `POST /api/terminals/:id/resize` — resizes the session's pty. */
193
+ export const terminalResizeRoute = defineJsonRoute({
194
+ method: 'post', path: '/api/terminals/:id/resize', parse: parseResizeInput, handle: async ({ input, deps }) => {
195
+ const result = await deps.manager.resize({ principal: deps.principal, id: input.id, cols: input.cols, rows: input.rows });
196
+ return actionResultToApiResult({ result, notFoundMessage: `terminal "${input.id}" was not found` });
197
+ }
198
+ }, { requireSameOrigin: true });
199
+ function handleKill(id, deps) {
200
+ return deps.manager.kill({ principal: deps.principal, id }, { signal: 'SIGTERM' }).then((result) => actionResultToApiResult({ result, notFoundMessage: `terminal "${id}" was not found` }));
201
+ }
202
+ /** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
203
+ export const terminalKillRoute = defineJsonRoute({ method: 'post', path: '/api/terminals/:id/kill', parse: parseTerminalId, handle: ({ input: id, deps }) => handleKill(id, deps) }, { requireSameOrigin: true });
204
+ /** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
205
+ export const terminalDeleteRoute = defineJsonRoute({ method: 'delete', path: '/api/terminals/:id', parse: parseTerminalId, handle: ({ input: id, deps }) => handleKill(id, deps) }, { requireSameOrigin: true });
206
+ /**
207
+ * The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
208
+ * `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
209
+ * function's inline comment for *why* the deferral exists (an already-exited session's replay
210
+ * path can call `end()` before `open()` has ever run).
211
+ *
212
+ * **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
213
+ * ordering every *currently reachable* production call graph in this repo takes for a live
214
+ * session that exits while already streaming — but it is unreachable through that graph, not
215
+ * unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
216
+ * terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
217
+ * client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
218
+ * === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
219
+ * which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
220
+ * detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
221
+ * `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
222
+ * snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
223
+ * `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
224
+ * this repo's own `@jini-ai/platform` + `@jini-ai/daemon/http` composition can currently produce (confirmed by
225
+ * instrumenting the real call path directly, not inferred from reading the source — a synchronous
226
+ * console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
227
+ * That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
228
+ * future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
229
+ * sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
230
+ * exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
231
+ * here rather than deleted for being unreachable *today* or left silently uncovered.
232
+ */
233
+ export function createDeferredEndGate({ channel }, _optional = {}) {
234
+ let opened = false;
235
+ let endRequestedBeforeOpen = false;
236
+ return {
237
+ markOpened() {
238
+ opened = true;
239
+ // Idempotent-safe even if the queued 'exit' event already auto-closed the channel via
240
+ // `isEndEvent` during `open()`'s own drain (`channel.end()` is documented safe to call twice).
241
+ if (endRequestedBeforeOpen)
242
+ channel.end();
243
+ },
244
+ end() {
245
+ if (opened) {
246
+ channel.end();
247
+ }
248
+ else {
249
+ endRequestedBeforeOpen = true;
250
+ }
251
+ },
252
+ };
253
+ }
254
+ /**
255
+ * `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
256
+ * reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
257
+ * `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
258
+ * `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
259
+ * `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
260
+ *
261
+ * Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
262
+ * `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
263
+ * directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
264
+ */
265
+ export function registerTerminalEventStream({ app, deps, adapter }, _optional = {}) {
266
+ app.get('/api/terminals/:id/stream', (req, res) => {
267
+ const origin = guardSameOrigin({ req, origin: adapter });
268
+ if (!origin.ok) {
269
+ sendApiError({ res, status: statusForError({ error: origin.error }), error: origin.error });
270
+ return;
271
+ }
272
+ const id = req.params.id;
273
+ if (typeof id !== 'string' || id.length === 0) {
274
+ sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'id must be a non-empty path parameter' }) });
275
+ return;
276
+ }
277
+ const cursor = requestedAfterCursor(req);
278
+ const lastEventId = cursor === null ? 0 : Number(cursor);
279
+ const channel = createSseChannel({ res }, { isEndEvent: ({ event }) => event.kind === 'exit' });
280
+ // `deps.manager.attach` can call `sink.end()` synchronously, from inside the very call below
281
+ // (an already-exited session's replay path) — before the channel has ever been `open()`ed. If
282
+ // `end()` mapped straight to `channel.end()`, that would end the response with the queued
283
+ // backlog never flushed (headers never even sent). `createDeferredEndGate` defers the actual
284
+ // `channel.end()` call until after `open()` has drained whatever `send()` already queued — see
285
+ // that function's own doc for the full reachability analysis of its other branch.
286
+ const deferredEnd = createDeferredEndGate({ channel });
287
+ const sink = {
288
+ send(event, data, eventId) {
289
+ channel.enqueue({ event: { opaqueCursor: String(eventId), kind: event, data } });
290
+ },
291
+ end() {
292
+ deferredEnd.end();
293
+ },
294
+ };
295
+ let attachedSink = null;
296
+ channel.onClose({
297
+ callback: () => {
298
+ if (attachedSink)
299
+ deps.manager.detach({ id, sink: attachedSink });
300
+ }
301
+ });
302
+ const result = deps.manager.attach({ principal: deps.principal, id, lastEventId, sink });
303
+ if (result === 'not-found') {
304
+ channel.abandon();
305
+ sendApiError({ res, status: 404, error: createApiError({ code: 'NOT_FOUND', message: `terminal "${id}" was not found` }) });
306
+ return;
307
+ }
308
+ if (result === 'attached') {
309
+ attachedSink = sink;
310
+ }
311
+ channel.open();
312
+ deferredEnd.markOpened();
313
+ });
314
+ }
315
+ /** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
316
+ export function registerTerminalRoutes({ app, deps, adapter }, _optional = {}) {
317
+ mountJsonRoute({ app, spec: terminalListRoute, deps, adapter });
318
+ mountJsonRoute({ app, spec: terminalCreateRoute, deps, adapter });
319
+ mountJsonRoute({ app, spec: terminalStdinRoute, deps, adapter });
320
+ mountJsonRoute({ app, spec: terminalResizeRoute, deps, adapter });
321
+ mountJsonRoute({ app, spec: terminalKillRoute, deps, adapter });
322
+ mountJsonRoute({ app, spec: terminalDeleteRoute, deps, adapter });
323
+ registerTerminalEventStream({ app, deps, adapter });
324
+ }
325
+ //# sourceMappingURL=terminals.js.map
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @module tool-catalog
3
+ *
4
+ * `GET /api/tools/search` / `GET /api/tools/:id` — the discovery half of
5
+ * `ai-control-plane.md` §29 / `PROP-tool-catalog-discovery-2026-07-26.md` §6.1's staged verbs
6
+ * (`search_tools`/`describe_tool`), v0 scope: no principal scoping, no versioning, no schema
7
+ * narrowing. `@jini-ai/mcp`'s `search_tools`/`describe_tool` tool defs proxy these two routes,
8
+ * exactly mirroring how `run-tools.ts`'s tools proxy `runs.ts`.
9
+ *
10
+ * This module does not depend on `@jini-ai/sqlite-chat` or `better-sqlite3` — `ToolCatalogQuery` is a
11
+ * plain injected interface, structurally compatible with `@jini-ai/sqlite-chat`'s
12
+ * `searchToolCatalog`/`getToolCatalogEntry` so a caller wires those in with zero adapter code,
13
+ * matching `db-ops.ts`'s established convention.
14
+ *
15
+ * **Deliberately not routed through `ToolExecutor`.** Unlike `db-ops.ts`'s three tools, nothing
16
+ * here executes a handler or has a side effect — it is a read over a snapshot table. `PROP` §6.3:
17
+ * "discovery is not permission... the catalog is never consulted for authorization." The actual
18
+ * gate is still `ToolExecutor`, reached only via `execute_delegated_tool`
19
+ * (`packages/daemon/src/delegated-tool-bridge.ts`) — finding a tool id here grants nothing.
20
+ */
21
+ import type { Express } from 'express';
22
+ import { type AdapterContext } from '@jini-ai/http-kit';
23
+ export interface ToolCatalogSearchHit {
24
+ readonly id: string;
25
+ readonly description: string;
26
+ readonly source: string;
27
+ readonly score: number;
28
+ }
29
+ export interface ToolCatalogEntry {
30
+ readonly id: string;
31
+ readonly description: string;
32
+ readonly inputSchema?: unknown;
33
+ readonly source: string;
34
+ }
35
+ /** Structurally identical to `@jini-ai/sqlite-chat`'s `searchToolCatalog`/`getToolCatalogEntry` — defined locally so this package incurs no `better-sqlite3` dependency. */
36
+ export interface ToolCatalogQuery {
37
+ readonly search: ({ query }: {
38
+ readonly query: string;
39
+ }, optionalArgs?: {
40
+ readonly limit?: number | undefined;
41
+ }) => readonly ToolCatalogSearchHit[];
42
+ readonly describe: ({ id }: {
43
+ readonly id: string;
44
+ }, _optional?: Record<string, never>) => ToolCatalogEntry | null;
45
+ }
46
+ export interface ToolCatalogHttpDeps {
47
+ readonly catalog: ToolCatalogQuery;
48
+ }
49
+ /** `GET /api/tools/search?q=...&limit=...` — ranked candidates only, never schemas (`PROP` §6.1). */
50
+ export declare const toolCatalogSearchRoute: import("@jini-ai/http-kit").JsonRouteSpec<{
51
+ query: string;
52
+ limit: number;
53
+ }, {
54
+ hits: readonly ToolCatalogSearchHit[];
55
+ }, ToolCatalogHttpDeps>;
56
+ /**
57
+ * `GET /api/tools/:id` — full descriptor (schema included), paid for only once a candidate survives
58
+ * search.
59
+ *
60
+ * An unknown tool id is **404 `NOT_FOUND`**, not 400. A well-formed request naming a catalog entry
61
+ * that does not exist is a missing resource, not a malformed request — and the three sibling route
62
+ * families in this package that have the same "the referenced thing isn't there" case
63
+ * (`memory.ts`'s `memory not found`, `routines.ts`'s `routine not found`, `media.ts`'s
64
+ * `media task not found`) all already answer 404. This route answered 400 until 2026-07-29 purely
65
+ * because it reached for `validationError` — the same helper its `parse` step uses, where 400 *is*
66
+ * correct — so a caller could not distinguish "you sent a bad request" from "that tool is gone".
67
+ * `parse` failures here (a missing `:id` segment) are still 400.
68
+ */
69
+ export declare const toolCatalogDescribeRoute: import("@jini-ai/http-kit").JsonRouteSpec<{
70
+ id: string;
71
+ }, ToolCatalogEntry, ToolCatalogHttpDeps>;
72
+ /** Mounts both tool-catalog routes on `app`. A pack's `http({ app, services })` calls this directly. */
73
+ export declare function registerToolCatalogRoutes({ app, deps, adapter }: {
74
+ readonly app: Express;
75
+ readonly deps: ToolCatalogHttpDeps;
76
+ readonly adapter: AdapterContext;
77
+ }, _optional?: Record<string, never>): void;
78
+ //# sourceMappingURL=tool-catalog.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { createApiError } from '@jini-ai/protocol';
2
+ import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
3
+ import { validationError } from '@jini-ai/http-kit';
4
+ import { err, ok } from '@jini-ai/http-kit';
5
+ const MAX_SEARCH_LIMIT = 25;
6
+ const DEFAULT_SEARCH_LIMIT = 10;
7
+ function parseSearchInput(input) {
8
+ const rawQuery = input.query.q;
9
+ if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
10
+ return err({ error: validationError({ message: 'q (search query) is required and must be a non-empty string' }) });
11
+ }
12
+ const rawLimit = input.query.limit;
13
+ if (rawLimit === undefined)
14
+ return ok({ value: { query: rawQuery, limit: DEFAULT_SEARCH_LIMIT } });
15
+ if (typeof rawLimit !== 'string' || !/^[0-9]+$/.test(rawLimit)) {
16
+ return err({ error: validationError({ message: 'limit must be a single positive-integer query-string value when provided' }) });
17
+ }
18
+ const parsed = Number(rawLimit);
19
+ if (parsed < 1)
20
+ return err({ error: validationError({ message: 'limit must be at least 1' }) });
21
+ return ok({ value: { query: rawQuery, limit: Math.min(parsed, MAX_SEARCH_LIMIT) } });
22
+ }
23
+ /** `GET /api/tools/search?q=...&limit=...` — ranked candidates only, never schemas (`PROP` §6.1). */
24
+ export const toolCatalogSearchRoute = defineJsonRoute({
25
+ method: 'get',
26
+ path: '/api/tools/search',
27
+ parse: parseSearchInput,
28
+ handle: ({ input, deps }) => ok({ value: { hits: deps.catalog.search({ query: input.query }, { limit: input.limit }) } }),
29
+ }, { requireSameOrigin: true });
30
+ function parseDescribeInput(input) {
31
+ const id = input.params.id;
32
+ if (typeof id !== 'string' || id.length === 0) {
33
+ return err({ error: validationError({ message: 'id path segment is required' }) });
34
+ }
35
+ return ok({ value: { id } });
36
+ }
37
+ /**
38
+ * `GET /api/tools/:id` — full descriptor (schema included), paid for only once a candidate survives
39
+ * search.
40
+ *
41
+ * An unknown tool id is **404 `NOT_FOUND`**, not 400. A well-formed request naming a catalog entry
42
+ * that does not exist is a missing resource, not a malformed request — and the three sibling route
43
+ * families in this package that have the same "the referenced thing isn't there" case
44
+ * (`memory.ts`'s `memory not found`, `routines.ts`'s `routine not found`, `media.ts`'s
45
+ * `media task not found`) all already answer 404. This route answered 400 until 2026-07-29 purely
46
+ * because it reached for `validationError` — the same helper its `parse` step uses, where 400 *is*
47
+ * correct — so a caller could not distinguish "you sent a bad request" from "that tool is gone".
48
+ * `parse` failures here (a missing `:id` segment) are still 400.
49
+ */
50
+ export const toolCatalogDescribeRoute = defineJsonRoute({
51
+ method: 'get',
52
+ path: '/api/tools/:id',
53
+ parse: parseDescribeInput,
54
+ handle: ({ input, deps }) => {
55
+ const entry = deps.catalog.describe({ id: input.id });
56
+ if (!entry)
57
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: `no catalog entry for tool id "${input.id}"` }) });
58
+ return ok({ value: entry });
59
+ },
60
+ }, { requireSameOrigin: true });
61
+ /** Mounts both tool-catalog routes on `app`. A pack's `http({ app, services })` calls this directly. */
62
+ export function registerToolCatalogRoutes({ app, deps, adapter }, _optional = {}) {
63
+ mountJsonRoute({ app, spec: toolCatalogSearchRoute, deps, adapter });
64
+ mountJsonRoute({ app, spec: toolCatalogDescribeRoute, deps, adapter });
65
+ }
66
+ //# sourceMappingURL=tool-catalog.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Generic, host-injectable "workspace root" resolution port.
3
+ *
4
+ * Several OD route packs need the same thing at the boundary: "given some
5
+ * opaque resource reference, what filesystem directory does a spawned
6
+ * process or launched editor run in?" OD answers that with
7
+ * `ctx.projectStore.getProject` + `ctx.projectFiles.resolveProjectDir` — a
8
+ * project-store lookup plus a metadata-driven directory resolver, both
9
+ * product-specific (see `packages/http/archived provenance ledger`'s routes-classification
10
+ * table, rows `#11 terminal.ts` and `#23 host-tools.ts`, both of which name
11
+ * exactly this dependency as the reason `POST /api/projects/:id/open-in` and
12
+ * the interactive-terminal spawn route were not ported earlier). The kernel
13
+ * has no concept of "project," so it cannot hardcode that lookup — but it
14
+ * also must not silently invent a directory (guessing `process.cwd()` or an
15
+ * OS temp dir would let one request's resource reference leak into an
16
+ * unrelated working directory).
17
+ *
18
+ * Follows the same explicit-injection, conservative-default shape this repo
19
+ * already established for `@jini-ai/cli`'s `resolveDaemonUrl`
20
+ * (`packages/cli/src/daemon-url.ts`): a host supplies a resolver callback; the
21
+ * built-in default resolves nothing and the resolution function throws
22
+ * rather than guessing. It also mirrors `active-context.ts`'s
23
+ * `resolveResource` DI shape (a caller-supplied lookup replacing OD's
24
+ * `getProject`) and its `resourceRef`/`detail` field naming.
25
+ */
26
+ /** What a route needs resolved: an opaque resource reference, plus an optional sub-locator within it (e.g. a specific file), matching `active-context.ts`'s `resourceRef`/`detail` naming. */
27
+ export interface WorkspaceRootRequest {
28
+ readonly resourceRef: string;
29
+ readonly detail?: string | null;
30
+ }
31
+ /**
32
+ * Host-supplied lookup: given a {@link WorkspaceRootRequest}, return the
33
+ * absolute filesystem path a process/editor should be rooted at, or a
34
+ * nullish value when this host has no directory for that resource (an
35
+ * unknown id, a resource with no on-disk representation, or a caller not
36
+ * permitted to resolve it — the resolver decides, the kernel does not).
37
+ */
38
+ export type WorkspaceRootResolver = (requiredArgs: {
39
+ readonly request: WorkspaceRootRequest;
40
+ }) => string | null | undefined | Promise<string | null | undefined>;
41
+ /** Thrown by {@link resolveWorkspaceRoot} when no root could be resolved — the caller has nothing safe to spawn a process or open an editor against. */
42
+ export declare class WorkspaceRootDeniedError extends Error {
43
+ readonly resourceRef: string;
44
+ constructor({ resourceRef }: {
45
+ readonly resourceRef: string;
46
+ }, { reason }?: {
47
+ readonly reason?: string | undefined;
48
+ });
49
+ }
50
+ /**
51
+ * The built-in, zero-config default resolver: resolves nothing, for every
52
+ * request. A host that never wires its own resolver gets a hard denial from
53
+ * every call site that needs a workspace root — never a guessed path. This
54
+ * is the "conservative default that denies rather than guessing" half of
55
+ * the port, matching the task's explicit precedent
56
+ * (`ToolPolicy`/`resolveDaemonUrl`).
57
+ */
58
+ export declare const denyAllWorkspaceRoots: WorkspaceRootResolver;
59
+ export interface ResolveWorkspaceRootOptions {
60
+ /** Defaults to {@link denyAllWorkspaceRoots} — a host must explicitly opt in to resolving real paths. */
61
+ readonly resolver?: WorkspaceRootResolver;
62
+ }
63
+ /**
64
+ * Resolves a workspace root for `request` via `options.resolver`.
65
+ *
66
+ * @throws {WorkspaceRootDeniedError} If the resolver is omitted, or returns
67
+ * `null`/`undefined`/an empty string. This function never falls back to a
68
+ * guessed path (`process.cwd()`, an OS temp dir, `resourceRef` itself
69
+ * treated as a path) — a route that cannot resolve a real root has nothing
70
+ * safe to act on.
71
+ * @complexity O(1) plus the injected resolver's own cost.
72
+ * @overallScore 100/100
73
+ */
74
+ export declare function resolveWorkspaceRoot({ request }: {
75
+ readonly request: WorkspaceRootRequest;
76
+ }, options?: ResolveWorkspaceRootOptions): Promise<string>;
77
+ //# sourceMappingURL=workspace-root.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Generic, host-injectable "workspace root" resolution port.
3
+ *
4
+ * Several OD route packs need the same thing at the boundary: "given some
5
+ * opaque resource reference, what filesystem directory does a spawned
6
+ * process or launched editor run in?" OD answers that with
7
+ * `ctx.projectStore.getProject` + `ctx.projectFiles.resolveProjectDir` — a
8
+ * project-store lookup plus a metadata-driven directory resolver, both
9
+ * product-specific (see `packages/http/archived provenance ledger`'s routes-classification
10
+ * table, rows `#11 terminal.ts` and `#23 host-tools.ts`, both of which name
11
+ * exactly this dependency as the reason `POST /api/projects/:id/open-in` and
12
+ * the interactive-terminal spawn route were not ported earlier). The kernel
13
+ * has no concept of "project," so it cannot hardcode that lookup — but it
14
+ * also must not silently invent a directory (guessing `process.cwd()` or an
15
+ * OS temp dir would let one request's resource reference leak into an
16
+ * unrelated working directory).
17
+ *
18
+ * Follows the same explicit-injection, conservative-default shape this repo
19
+ * already established for `@jini-ai/cli`'s `resolveDaemonUrl`
20
+ * (`packages/cli/src/daemon-url.ts`): a host supplies a resolver callback; the
21
+ * built-in default resolves nothing and the resolution function throws
22
+ * rather than guessing. It also mirrors `active-context.ts`'s
23
+ * `resolveResource` DI shape (a caller-supplied lookup replacing OD's
24
+ * `getProject`) and its `resourceRef`/`detail` field naming.
25
+ */
26
+ /** Thrown by {@link resolveWorkspaceRoot} when no root could be resolved — the caller has nothing safe to spawn a process or open an editor against. */
27
+ export class WorkspaceRootDeniedError extends Error {
28
+ resourceRef;
29
+ constructor({ resourceRef }, { reason } = {}) {
30
+ super(reason ?? `no workspace root available for resource "${resourceRef}"`);
31
+ this.name = 'WorkspaceRootDeniedError';
32
+ this.resourceRef = resourceRef;
33
+ }
34
+ }
35
+ /**
36
+ * The built-in, zero-config default resolver: resolves nothing, for every
37
+ * request. A host that never wires its own resolver gets a hard denial from
38
+ * every call site that needs a workspace root — never a guessed path. This
39
+ * is the "conservative default that denies rather than guessing" half of
40
+ * the port, matching the task's explicit precedent
41
+ * (`ToolPolicy`/`resolveDaemonUrl`).
42
+ */
43
+ export const denyAllWorkspaceRoots = () => null;
44
+ /**
45
+ * Resolves a workspace root for `request` via `options.resolver`.
46
+ *
47
+ * @throws {WorkspaceRootDeniedError} If the resolver is omitted, or returns
48
+ * `null`/`undefined`/an empty string. This function never falls back to a
49
+ * guessed path (`process.cwd()`, an OS temp dir, `resourceRef` itself
50
+ * treated as a path) — a route that cannot resolve a real root has nothing
51
+ * safe to act on.
52
+ * @complexity O(1) plus the injected resolver's own cost.
53
+ * @overallScore 100/100
54
+ */
55
+ export async function resolveWorkspaceRoot({ request }, options = {}) {
56
+ const resolver = options.resolver ?? denyAllWorkspaceRoots;
57
+ const root = await resolver({ request });
58
+ if (typeof root !== 'string' || root.length === 0) {
59
+ throw new WorkspaceRootDeniedError({ resourceRef: request.resourceRef });
60
+ }
61
+ return root;
62
+ }
63
+ //# sourceMappingURL=workspace-root.js.map