@jini-ai/server 0.0.0-stage → 0.5.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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * @module builtin-features
3
+ *
4
+ * Every route family this package can mount, expressed as a `JiniFeature` whose `compose` returns
5
+ * one atomic `@jini-ai/core` `Pack`.
6
+ *
7
+ * The load-bearing property is that a family's **tools and its routes are the same pack**. Before
8
+ * this module, `create-local-node-daemon.ts` registered the terminal tool and the three `daemon.db.*`
9
+ * tools into a private registry in one place, and mounted `registerTerminalRoutes`/
10
+ * `registerDaemonDbRoutes` in another — so gating only the route registrations would have left
11
+ * `jini.terminal.create` registered and still reachable through the always-mounted
12
+ * `POST /api/delegated-tool-calls`. Here, a feature that is not selected contributes neither, and
13
+ * there is no second step to gate. `terminal: false` means the pty tool is *absent from the
14
+ * registry*, so a delegated call naming it is an unregistered-tool error rather than a shell.
15
+ *
16
+ * Features borrow kernel infrastructure (`ctx.kernel`) and never own it — see `kernel-base.ts` for
17
+ * why the sqlite connection in particular is shared rather than feature-owned.
18
+ */
19
+ import { statSync } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import { definePack } from '@jini-ai/core';
22
+ import { detectAgents, getAgentDef } from '@jini-ai/agent-runtime';
23
+ import { createDefaultRunStartHandler, createRemoteToolEventRecorder, isAgentExecutorSupported, createTerminalSessionManager, createTerminalToolRegistrations, } from '@jini-ai/daemon';
24
+ import { ensureToolCatalogTables, getToolCatalogEntry, reseedToolCatalog, searchToolCatalog, } from '@jini-ai/registry/tool-catalog/sqlite';
25
+ import { inspectSqliteDatabase, verifySqliteIntegrity } from '@jini-ai/db/sqlite';
26
+ import { createDaemonDbToolRegistrations, denyAllWorkspaceRoots, registerActiveContextRoutes, registerAgentRoutes, registerConnectorsRoutes, registerDaemonDbRoutes, registerDaemonStatusRoutes, registerDelegatedToolRoutes, registerFrontendSessionRoutes, registerHealthRoutes, registerHostToolsRoutes, registerMediaRoutes, registerMemoryRoutes, registerModelProxyRoutes, registerRemoteRunEventRoutes, registerResearchRoutes, registerRoutineRoutes, registerRunRoutes, registerTerminalRoutes, registerToolCatalogRoutes, registerXaiRoutes } from '@jini-ai/daemon/http';
27
+ import { defineJiniFeature } from './feature.js';
28
+ /**
29
+ * The identity every zero-config, `ToolExecutor`-gated tool this module registers (`terminal.create`,
30
+ * `daemon.db.*`) executes as. A composition using these has no multi-tenant identity subsystem of
31
+ * its own — the bearer gate is authenticated-or-not, not per-caller — so every authenticated caller
32
+ * already shares one trust boundary and a single fixed `Principal` removes no distinction that
33
+ * existed before it.
34
+ */
35
+ export const LOCAL_DAEMON_PRINCIPAL = { id: 'local-daemon' };
36
+ const require = createRequire(import.meta.url);
37
+ /** This package's own version, the default a composition reports when a host names none. */
38
+ const packageVersion = require('../package.json').version;
39
+ /**
40
+ * The identity a delegated tool call runs as when the host supplies no resolver. Carries **no
41
+ * roles**, deliberately.
42
+ *
43
+ * ## What actually makes this inert, because an earlier version of this comment named the wrong
44
+ * mechanism
45
+ *
46
+ * It previously claimed inertness came from "every registered tool's own deny-by-default
47
+ * `ToolPolicy`". That is **false**, and believing it is dangerous. `@jini-ai/cms` registers every
48
+ * one of its tools with a pass-through `policy: { authorize: () => 'allow' }` — deliberately, and
49
+ * documented as such in `core/tools/registration-kit.ts`: each tool's permission is evaluated
50
+ * exactly once, inside the domain function or via that file's `requireToolPermission`, so a second
51
+ * evaluator in the policy would be the bug rather than the guard.
52
+ *
53
+ * What actually denies this principal is the identity layer being fail-closed on an UNKNOWN
54
+ * subject: `identity/authorize.ts` starts with `principals.findById(...)` and returns
55
+ * `{ allowed: false, reason: 'principal_disabled' }` when no row comes back. `anonymous-delegated`
56
+ * has no row, so every permission-gated tool refuses it.
57
+ *
58
+ * ## The residual risk that follows from that, stated plainly
59
+ *
60
+ * The protection is the *tool's own* permission check, not this identity and not the registry. A
61
+ * tool registered by some other host with a permissive `ToolPolicy` **and** no internal permission
62
+ * check would execute for this principal. That is exactly why `@jini-ai/http-kit`'s
63
+ * `DelegatedToolsHttpDeps.resolvePrincipal` documents itself as mandatory, and why the fallback
64
+ * below warns rather than staying silent.
65
+ */
66
+ export const ANONYMOUS_DELEGATED_PRINCIPAL = { id: 'anonymous-delegated' };
67
+ /**
68
+ * The `resolvePrincipal` fallback, wrapped so the fallback is announced once at composition rather
69
+ * than taken silently. Warns at wiring time, not per request: a per-request warning on a route that
70
+ * can be called in a loop is a log-flood, and the thing worth knowing is a deployment fact, not a
71
+ * request fact.
72
+ */
73
+ function warnAndUseAnonymousDelegatedPrincipal() {
74
+ // eslint-disable-next-line no-console
75
+ console.warn('[@jini-ai/server] delegatedToolCalls is enabled without `resolvePrincipal`; delegated tool ' +
76
+ `calls will run as "${ANONYMOUS_DELEGATED_PRINCIPAL.id}", which every permission-gated tool ` +
77
+ 'refuses (the identity layer is fail-closed on an unknown subject) but a tool with a ' +
78
+ 'permissive ToolPolicy and no internal permission check would still execute for.');
79
+ return () => ANONYMOUS_DELEGATED_PRINCIPAL;
80
+ }
81
+ /** Thrown when a feature is activated without the host-supplied options it cannot default. */
82
+ function requireOptions(value, featureId, optionPath) {
83
+ if (value === undefined) {
84
+ throw new Error(`jini: feature "${featureId}" is active but "${optionPath}" was not supplied — ` +
85
+ `this feature has no zero-config default. Supply it, or disable "${featureId}".`);
86
+ }
87
+ return value;
88
+ }
89
+ function requireSqlite(context, featureId) {
90
+ if (context.kernel.sqlite === null) {
91
+ throw new Error(`jini: feature "${featureId}" needs sqlite storage but this composition uses memory storage — ` +
92
+ `use storage: { kind: 'sqlite', dataDir } or disable "${featureId}".`);
93
+ }
94
+ return context.kernel.sqlite;
95
+ }
96
+ /**
97
+ * `DaemonDbOperations` over a caller-owned connection. `vacuum` measures the primary file's on-disk
98
+ * size before/after (not the `-wal`/`-shm` sum), since a fresh `VACUUM` checkpoints and shrinks the
99
+ * primary file itself, which is what "reclaimed" means here.
100
+ */
101
+ export function buildDaemonDbOperations(db, file) {
102
+ return {
103
+ inspect: () => inspectSqliteDatabase({ db, file }),
104
+ verify: ({ quick }) => verifySqliteIntegrity({ db, quick }),
105
+ vacuum: () => {
106
+ const startedAt = Date.now();
107
+ const beforeBytes = statSync(file).size;
108
+ db.exec('VACUUM');
109
+ const afterBytes = statSync(file).size;
110
+ return {
111
+ ok: true,
112
+ beforeBytes,
113
+ afterBytes,
114
+ reclaimedBytes: Math.max(0, beforeBytes - afterBytes),
115
+ elapsedMs: Date.now() - startedAt,
116
+ };
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * Whether the `agents` feature should advertise this probe result.
122
+ *
123
+ * Discovery scans every runtime definition, but `AgentExecutor` refuses to drive any def its
124
+ * `assessAgentExecutorCompatibility` rejects — see `@jini-ai/daemon`'s own module doc. All currently
125
+ * registered defs pass that check, but a future def may not. Advertising one that doesn't yields an
126
+ * agent a user can select and then cannot run — so the executor's own compatibility answer is applied
127
+ * here, at the surface that offers the choice.
128
+ *
129
+ * Assessed against the **full** `RuntimeAgentDef` resolved by id, not the `DetectedAgent` passed in:
130
+ * the projected type omits `maxPromptArgBytes`, and the argv-bound defs (`aider`, `deepseek`) qualify
131
+ * solely through it, so judging the projection would drop two working agents.
132
+ *
133
+ * An id with no registered def is **kept**, not dropped. A host supplying its own `detector` may
134
+ * legitimately surface agents outside `AGENT_DEFS`, driven by something other than this executor;
135
+ * there is nothing to assess, and refusing to advertise them would break that host on no evidence.
136
+ *
137
+ * @param agent - One probe result from the active detector.
138
+ * @returns `true` when the agent should appear in `GET /api/agents`.
139
+ * @complexity O(1) — one registry lookup plus fixed field checks.
140
+ * @overallScore 100/100
141
+ */
142
+ export function isExecutableDetectedAgent(agent) {
143
+ const def = getAgentDef({ id: agent.id });
144
+ return def === null || isAgentExecutorSupported({ def });
145
+ }
146
+ /** Projects a real `DetectedAgent` probe onto the HTTP summary shape. */
147
+ export function projectDetectedAgent(agent) {
148
+ return {
149
+ id: agent.id,
150
+ name: agent.name,
151
+ available: agent.available,
152
+ ...(agent.version !== undefined ? { version: agent.version } : {}),
153
+ ...(agent.authStatus !== undefined ? { authStatus: agent.authStatus } : {}),
154
+ models: agent.models.map(({ id, label }) => ({ id, label })),
155
+ ...(agent.reasoningOptions !== undefined
156
+ ? { reasoningOptions: agent.reasoningOptions.map(({ id, label }) => ({ id, label })) }
157
+ : {}),
158
+ modelsSource: agent.modelsSource,
159
+ ...(agent.supportsCustomModel !== undefined ? { supportsCustomModel: agent.supportsCustomModel } : {}),
160
+ ...(agent.diagnostics?.[0]?.message ? { diagnostic: agent.diagnostics[0].message } : {}),
161
+ };
162
+ }
163
+ /**
164
+ * Builds the full built-in feature catalog with `options` closed over.
165
+ *
166
+ * Order is composition order, and it is deliberately the order `createLocalNodeDaemon` has always
167
+ * registered these families in — so the route-registration inventory, the tool-registration order,
168
+ * and therefore which id a duplicate-registration error names, are all unchanged.
169
+ */
170
+ export function createBuiltInFeatures(options = {}) {
171
+ const healthFeature = defineJiniFeature({
172
+ id: 'health',
173
+ // No capability: a liveness/readiness probe grants no authority and is always permitted.
174
+ provides: [],
175
+ phase: 'probe',
176
+ compose: (context) => ({
177
+ pack: definePack({
178
+ name: 'jini.health',
179
+ deps: [],
180
+ services: () => ({ context }),
181
+ }, {
182
+ http: ({ app, services }) => {
183
+ const sqlite = services.context.kernel.sqlite;
184
+ const isShuttingDown = options.health?.isShuttingDown ?? (() => false);
185
+ registerHealthRoutes({ app: app, deps: {
186
+ getVersion: options.health?.getVersion ?? (() => packageVersion),
187
+ // Borrows the kernel's connection — see kernel-base.ts on why this is not
188
+ // `daemonDb`'s to own. A readiness check must never itself throw and crash the
189
+ // route: a thrown pragma read is a legitimate "not ready" signal, not a 500.
190
+ checkReadiness: async () => {
191
+ const notShuttingDown = !isShuttingDown();
192
+ if (sqlite === null)
193
+ return { ok: notShuttingDown, checks: { notShuttingDown } };
194
+ let dbOk;
195
+ try {
196
+ dbOk = verifySqliteIntegrity({ db: sqlite.connection, quick: true }).ok;
197
+ }
198
+ catch {
199
+ dbOk = false;
200
+ }
201
+ return { ok: dbOk && notShuttingDown, checks: { db: dbOk, notShuttingDown } };
202
+ },
203
+ }, adapter: services.context.adapter });
204
+ },
205
+ }),
206
+ }),
207
+ });
208
+ const runsFeature = defineJiniFeature({
209
+ id: 'runs',
210
+ provides: ['run:transport'],
211
+ compose: (context) => {
212
+ // `onRunStarted` always wins when supplied. Otherwise a supplied `resolveRunInput` gets the
213
+ // default handler built for it, driving the kernel's own `AgentExecutor`. With neither, runs
214
+ // durably start with no driver attached.
215
+ const onStarted = options.runs?.onRunStarted ??
216
+ (options.runs?.resolveRunInput === undefined
217
+ ? undefined
218
+ : createDefaultRunStartHandler({
219
+ agentExecutor: context.kernel.agentExecutor,
220
+ resolveRunInput: options.runs.resolveRunInput,
221
+ }));
222
+ return {
223
+ pack: definePack({
224
+ name: 'jini.runs',
225
+ deps: [],
226
+ services: () => ({ context }),
227
+ }, {
228
+ http: ({ app, services }) => {
229
+ registerRunRoutes({ app: app, deps: {
230
+ lifecycle: services.context.kernel.lifecycle,
231
+ ...(onStarted === undefined ? {} : { onStarted }),
232
+ }, adapter: services.context.adapter });
233
+ },
234
+ }),
235
+ };
236
+ },
237
+ });
238
+ const agentsFeature = defineJiniFeature({
239
+ id: 'agents',
240
+ provides: ['agent:discovery'],
241
+ compose: (context) => {
242
+ // Promise-cached so concurrent clients never spawn duplicate probe sets; invalidated by
243
+ // POST /api/agents/rescan.
244
+ const detector = options.agents?.detector ?? (() => detectAgents({}));
245
+ let scan = null;
246
+ const scanAgents = (force) => {
247
+ if (force)
248
+ scan = null;
249
+ if (!scan) {
250
+ const pending = detector()
251
+ // Filtered before projection so the executor's answer is computed from the full def —
252
+ // see `isExecutableDetectedAgent`'s doc for why the projected shape is not enough.
253
+ .then((agents) => agents.filter(isExecutableDetectedAgent).map(projectDetectedAgent))
254
+ .catch((error) => {
255
+ // Keyed by promise identity, not "whatever is cached when this failure lands": a slow
256
+ // scan that fails after `POST /api/agents/rescan` already replaced it with a newer,
257
+ // successful one must invalidate only its own entry. Clearing unconditionally evicts
258
+ // the newer result and forces a duplicate probe of every agent CLI on the machine.
259
+ if (scan === pending)
260
+ scan = null;
261
+ throw error;
262
+ });
263
+ scan = pending;
264
+ }
265
+ return scan;
266
+ };
267
+ return {
268
+ pack: definePack({
269
+ name: 'jini.agents',
270
+ deps: [],
271
+ services: () => ({ context }),
272
+ }, {
273
+ http: ({ app, services }) => {
274
+ registerAgentRoutes({ app: app, deps: { listAgents: () => scanAgents(false), rescanAgents: () => scanAgents(true) }, adapter: services.context.adapter });
275
+ },
276
+ }),
277
+ };
278
+ },
279
+ });
280
+ const hostToolsFeature = defineJiniFeature({
281
+ id: 'hostTools',
282
+ // Lists editors on PATH (`host:read`) and launches one (`host:exec`).
283
+ provides: ['host:read', 'host:exec'],
284
+ compose: (context) => ({
285
+ pack: definePack({
286
+ name: 'jini.hostTools',
287
+ deps: [],
288
+ services: () => ({ context }),
289
+ }, {
290
+ http: ({ app, services }) => {
291
+ registerHostToolsRoutes({ app: app, adapter: services.context.adapter }, {
292
+ resolveRoot: options.hostTools?.resolveWorkspaceRoot ?? denyAllWorkspaceRoots,
293
+ });
294
+ },
295
+ }),
296
+ }),
297
+ });
298
+ const modelProxyFeature = defineJiniFeature({
299
+ id: 'modelProxy',
300
+ provides: ['net:egress'],
301
+ compose: (context) => ({
302
+ pack: definePack({
303
+ name: 'jini.modelProxy',
304
+ deps: [],
305
+ services: () => ({ context }),
306
+ }, {
307
+ http: ({ app, services }) => registerModelProxyRoutes({ app: app, deps: {}, adapter: services.context.adapter }),
308
+ }),
309
+ }),
310
+ });
311
+ const activeContextFeature = defineJiniFeature({
312
+ id: 'activeContext',
313
+ provides: ['host:read'],
314
+ compose: (context) => ({
315
+ pack: definePack({
316
+ name: 'jini.activeContext',
317
+ deps: [],
318
+ services: () => ({ context }),
319
+ }, {
320
+ http: ({ app, services }) => registerActiveContextRoutes({ app: app, deps: // Mandatory but with an honest, harmless answer this package can always give:
321
+ // "unknown". It has no Project/Workspace noun of its own to resolve a name from.
322
+ { resolveResource: () => undefined }, adapter: services.context.adapter }),
323
+ }),
324
+ }),
325
+ });
326
+ const terminalFeature = defineJiniFeature({
327
+ id: 'terminal',
328
+ provides: ['host:exec'],
329
+ compose: (context) => {
330
+ const manager = createTerminalSessionManager({});
331
+ return {
332
+ pack: definePack({
333
+ name: 'jini.terminal',
334
+ deps: [],
335
+ services: () => ({ context, manager }),
336
+ }, {
337
+ // THE gap closure: the pty tool and the pty routes are one contribution. A composition
338
+ // without this feature never registers `jini.terminal.create` at all, so a delegated call
339
+ // naming it is an unregistered-tool error — not a shell.
340
+ tools: ({ services }) => [
341
+ createTerminalToolRegistrations({ manager: services.manager }).create,
342
+ ],
343
+ http: ({ app, services }) => registerTerminalRoutes({ app: app, deps: {
344
+ manager: services.manager,
345
+ toolExecutor: services.context.kernel.toolExecutor,
346
+ principal: options.terminal?.principal ?? LOCAL_DAEMON_PRINCIPAL,
347
+ resolveRoot: options.terminal?.resolveWorkspaceRoot ?? denyAllWorkspaceRoots,
348
+ }, adapter: services.context.adapter }),
349
+ }),
350
+ };
351
+ },
352
+ });
353
+ const daemonDbFeature = defineJiniFeature({
354
+ id: 'daemonDb',
355
+ provides: ['db:admin'],
356
+ compose: (context) => {
357
+ const sqlite = requireSqlite(context, 'daemonDb');
358
+ // Borrowed, never owned: `kernel.close()` closes this connection. See kernel-base.ts.
359
+ const registrations = createDaemonDbToolRegistrations({
360
+ operations: buildDaemonDbOperations(sqlite.connection, sqlite.eventsDbPath),
361
+ });
362
+ return {
363
+ pack: definePack({
364
+ name: 'jini.daemonDb',
365
+ deps: [],
366
+ services: () => ({ context }),
367
+ }, {
368
+ tools: () => [registrations.inspect, registrations.verify, registrations.vacuum],
369
+ http: ({ app, services }) => registerDaemonDbRoutes({ app: app, deps: {
370
+ toolExecutor: services.context.kernel.toolExecutor,
371
+ principal: options.daemonDb?.principal ?? LOCAL_DAEMON_PRINCIPAL,
372
+ }, adapter: services.context.adapter }),
373
+ }),
374
+ };
375
+ },
376
+ });
377
+ const toolCatalogFeature = defineJiniFeature({
378
+ id: 'toolCatalog',
379
+ provides: ['tool:catalog'],
380
+ compose: (context) => {
381
+ const sqlite = requireSqlite(context, 'toolCatalog');
382
+ return {
383
+ pack: definePack({
384
+ name: 'jini.toolCatalog',
385
+ deps: [],
386
+ services: () => ({ context }),
387
+ }, {
388
+ http: ({ app, services }) => registerToolCatalogRoutes({ app: app, deps: {
389
+ catalog: {
390
+ search: ({ query }, { limit } = {}) => searchToolCatalog({ db: sqlite.connection, query }, limit === undefined ? {} : { limit }),
391
+ describe: ({ id }) => getToolCatalogEntry({ db: sqlite.connection, id }),
392
+ },
393
+ }, adapter: services.context.adapter }),
394
+ }),
395
+ // Runs once EVERY active feature's tools are registered — the one ordering a Pack cannot
396
+ // express itself. Seeding from inside this pack's own `tools` would snapshot a registry
397
+ // that is still being filled, and the catalog would be silently partial.
398
+ afterTools: () => {
399
+ ensureToolCatalogTables({ db: sqlite.connection });
400
+ reseedToolCatalog({ db: sqlite.connection, entries: context.kernel.registry.list({}).map((descriptor) => ({
401
+ id: descriptor.id,
402
+ description: descriptor.description ?? descriptor.id,
403
+ source: 'first-party',
404
+ ...(descriptor.inputSchema === undefined ? {} : { inputSchema: descriptor.inputSchema }),
405
+ })) });
406
+ },
407
+ };
408
+ },
409
+ });
410
+ const resolveDelegatedPrincipal = options.delegatedToolCalls?.resolvePrincipal;
411
+ const delegatedToolCallsFeature = defineJiniFeature({
412
+ id: 'delegatedToolCalls',
413
+ provides: ['tool:delegated'],
414
+ requires: ['runs'],
415
+ compose: (context) => ({
416
+ pack: definePack({
417
+ name: 'jini.delegatedToolCalls',
418
+ deps: [],
419
+ services: () => ({ context }),
420
+ }, {
421
+ http: ({ app, services }) => registerDelegatedToolRoutes({ app: app, deps: {
422
+ lifecycle: services.context.kernel.lifecycle,
423
+ toolExecutor: services.context.kernel.toolExecutor,
424
+ // The same registry `toolExecutor` was built over, so a `requireReadOnly` call (the
425
+ // daemon-side half of `@jini-ai/mcp`'s `execute_readonly_delegated_tool`) is checked
426
+ // against the descriptor that will actually run. Omitting it would not weaken the
427
+ // gate — an unverifiable constraint is refused, never waived — it would just make the
428
+ // read-only gateway refuse everything, so a kernel host gets it wired by default.
429
+ toolRegistry: services.context.kernel.registry,
430
+ // `DelegatedToolsHttpDeps.resolvePrincipal` documents itself as MANDATORY — "there is
431
+ // no safe default identity this package could assume on a host's behalf" — and this
432
+ // line supplied one anyway, silently. The default stays (removing it is a breaking
433
+ // change for hosts already relying on it, and is the owner's call, not this file's)
434
+ // but it no longer happens quietly: a host that enables this route without deciding
435
+ // who its calls run as should learn that from a startup line, not from an audit.
436
+ // See ANONYMOUS_DELEGATED_PRINCIPAL for what does and does not make it inert.
437
+ resolvePrincipal: resolveDelegatedPrincipal
438
+ ? ({ request }) => resolveDelegatedPrincipal(request)
439
+ : warnAndUseAnonymousDelegatedPrincipal(),
440
+ }, adapter: services.context.adapter }),
441
+ }),
442
+ }),
443
+ });
444
+ const connectorsFeature = defineJiniFeature({
445
+ id: 'connectors',
446
+ provides: ['net:egress'],
447
+ compose: (context) => ({
448
+ pack: definePack({
449
+ name: 'jini.connectors',
450
+ deps: [],
451
+ services: () => ({ context }),
452
+ }, {
453
+ http: ({ app, services }) => registerConnectorsRoutes({ app: app, deps: {}, adapter: services.context.adapter }),
454
+ }),
455
+ }),
456
+ });
457
+ const researchFeature = defineJiniFeature({
458
+ id: 'research',
459
+ provides: ['net:egress'],
460
+ compose: (context) => ({
461
+ pack: definePack({
462
+ name: 'jini.research',
463
+ deps: [],
464
+ services: () => ({ context }),
465
+ }, {
466
+ http: ({ app, services }) => registerResearchRoutes({ app: app, deps: {}, adapter: services.context.adapter }),
467
+ }),
468
+ }),
469
+ });
470
+ const xaiFeature = defineJiniFeature({
471
+ id: 'xai',
472
+ provides: ['net:egress'],
473
+ compose: (context) => {
474
+ const sqlite = requireSqlite(context, 'xai');
475
+ // Owned here (rather than left to xai.ts's internal default) so this feature's `dispose` can
476
+ // close an in-flight OAuth loopback listener — otherwise the fixed 127.0.0.1:56121 socket
477
+ // outlives the composition by up to its 30-minute self-close timeout.
478
+ const listenerRef = options.xai?.listenerRef ?? { current: null };
479
+ return {
480
+ pack: definePack({
481
+ name: 'jini.xai',
482
+ deps: [],
483
+ services: () => ({ context, listenerRef }),
484
+ }, {
485
+ http: ({ app, services }) => registerXaiRoutes({ app: app, deps: { dataDir: sqlite.dataDir, listenerRef: services.listenerRef }, adapter: services.context.adapter }),
486
+ dispose: async ({ services }) => {
487
+ const listener = services.listenerRef.current;
488
+ services.listenerRef.current = null;
489
+ // Best-effort — the listener self-closes on its own timeout anyway.
490
+ if (listener)
491
+ await listener.stop();
492
+ },
493
+ }),
494
+ };
495
+ },
496
+ });
497
+ const remoteRunEventsFeature = defineJiniFeature({
498
+ id: 'remoteRunEvents',
499
+ // Never granted by any shipped profile: writing agent events into a run this process does not
500
+ // own is strictly more authority than executing a tool through the policy-gated delegated route.
501
+ provides: ['run:inject'],
502
+ requires: ['runs'],
503
+ compose: (context) => ({
504
+ pack: definePack({
505
+ name: 'jini.remoteRunEvents',
506
+ deps: [],
507
+ services: () => ({ context }),
508
+ }, {
509
+ http: ({ app, services }) => registerRemoteRunEventRoutes({ app: app, deps: {
510
+ lifecycle: services.context.kernel.lifecycle,
511
+ recorder: createRemoteToolEventRecorder({ lifecycle: services.context.kernel.lifecycle }),
512
+ ...(options.remoteRunEvents?.tokenConfig === undefined
513
+ ? {}
514
+ : { tokenConfig: options.remoteRunEvents.tokenConfig }),
515
+ env: services.context.env,
516
+ }, adapter: services.context.adapter }),
517
+ }),
518
+ }),
519
+ });
520
+ const memoryFeature = defineJiniFeature({
521
+ id: 'memory',
522
+ provides: ['memory:store'],
523
+ compose: (context) => {
524
+ const deps = requireOptions(options.memory, 'memory', 'featureOptions.memory');
525
+ return {
526
+ pack: definePack({
527
+ name: 'jini.memory',
528
+ deps: [],
529
+ services: () => ({ context }),
530
+ }, {
531
+ http: ({ app, services }) => registerMemoryRoutes({ app: app, deps, adapter: services.context.adapter }),
532
+ }),
533
+ };
534
+ },
535
+ });
536
+ const routinesFeature = defineJiniFeature({
537
+ id: 'routines',
538
+ provides: ['routines:schedule'],
539
+ compose: (context) => {
540
+ const deps = requireOptions(options.routines, 'routines', 'featureOptions.routines');
541
+ return {
542
+ pack: definePack({
543
+ name: 'jini.routines',
544
+ deps: [],
545
+ services: () => ({ context }),
546
+ }, {
547
+ http: ({ app, services }) => registerRoutineRoutes({ app: app, deps, adapter: services.context.adapter }),
548
+ }),
549
+ };
550
+ },
551
+ });
552
+ const mediaFeature = defineJiniFeature({
553
+ id: 'media',
554
+ provides: ['media:generate'],
555
+ compose: (context) => {
556
+ const deps = requireOptions(options.media, 'media', 'featureOptions.media');
557
+ return {
558
+ pack: definePack({
559
+ name: 'jini.media',
560
+ deps: [],
561
+ services: () => ({ context }),
562
+ }, {
563
+ http: ({ app, services }) => registerMediaRoutes({ app: app, deps, adapter: services.context.adapter }),
564
+ }),
565
+ };
566
+ },
567
+ });
568
+ const frontendSessionsFeature = defineJiniFeature({
569
+ id: 'frontendSessions',
570
+ provides: ['ui:session'],
571
+ compose: (context) => {
572
+ const deps = requireOptions(options.frontendSessions, 'frontendSessions', 'featureOptions.frontendSessions');
573
+ return {
574
+ pack: definePack({
575
+ name: 'jini.frontendSessions',
576
+ deps: [],
577
+ services: () => ({ context }),
578
+ }, {
579
+ http: ({ app, services }) => registerFrontendSessionRoutes({ app: app, deps, adapter: services.context.adapter }),
580
+ }),
581
+ };
582
+ },
583
+ });
584
+ const daemonStatusFeature = defineJiniFeature({
585
+ id: 'daemonStatus',
586
+ provides: ['daemon:control'],
587
+ phase: 'status',
588
+ compose: (context) => {
589
+ const supplied = requireOptions(options.daemonStatus, 'daemonStatus', 'featureOptions.daemonStatus.requestShutdown');
590
+ const deps = {
591
+ getVersion: supplied.getVersion ?? (() => packageVersion),
592
+ host: supplied.host ?? '127.0.0.1',
593
+ getPort: supplied.getPort ?? (() => context.adapter.resolvedPortRef.current),
594
+ dataDir: supplied.dataDir ?? context.kernel.sqlite?.dataDir ?? '',
595
+ isShuttingDown: supplied.isShuttingDown ?? (() => false),
596
+ requestShutdown: supplied.requestShutdown,
597
+ };
598
+ return {
599
+ pack: definePack({
600
+ name: 'jini.daemonStatus',
601
+ deps: [],
602
+ services: () => ({ context }),
603
+ }, {
604
+ http: ({ app, services }) => registerDaemonStatusRoutes({ app: app, deps, adapter: services.context.adapter }),
605
+ }),
606
+ };
607
+ },
608
+ });
609
+ return [
610
+ healthFeature,
611
+ runsFeature,
612
+ agentsFeature,
613
+ hostToolsFeature,
614
+ modelProxyFeature,
615
+ activeContextFeature,
616
+ terminalFeature,
617
+ daemonDbFeature,
618
+ toolCatalogFeature,
619
+ delegatedToolCallsFeature,
620
+ connectorsFeature,
621
+ researchFeature,
622
+ xaiFeature,
623
+ remoteRunEventsFeature,
624
+ memoryFeature,
625
+ routinesFeature,
626
+ mediaFeature,
627
+ frontendSessionsFeature,
628
+ daemonStatusFeature,
629
+ ];
630
+ }
631
+ //# sourceMappingURL=builtin-features.js.map