@gmickel/gno 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +34 -18
  2. package/assets/skill/SKILL.md +25 -6
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +2 -2
  5. package/src/app/context-agent-projection.ts +303 -0
  6. package/src/app/context-format.ts +249 -0
  7. package/src/app/context-runtime-contract.ts +325 -0
  8. package/src/app/context-runtime-input.ts +362 -0
  9. package/src/app/context-runtime-types.ts +65 -0
  10. package/src/app/context-runtime.ts +170 -0
  11. package/src/app/context-surface.ts +145 -0
  12. package/src/cli/commands/context-build.ts +149 -0
  13. package/src/cli/commands/context-verify.ts +90 -0
  14. package/src/cli/commands/daemon.ts +69 -2
  15. package/src/cli/commands/models/pull.ts +13 -3
  16. package/src/cli/commands/status.ts +2 -0
  17. package/src/cli/detach.ts +37 -20
  18. package/src/cli/options.ts +4 -0
  19. package/src/cli/program.ts +252 -27
  20. package/src/config/index.ts +3 -0
  21. package/src/config/types.ts +37 -0
  22. package/src/core/context-budget.ts +461 -0
  23. package/src/core/context-capsule-index-schema.ts +15 -0
  24. package/src/core/context-capsule-retrieval-schema.ts +81 -0
  25. package/src/core/context-capsule-schema.ts +473 -0
  26. package/src/core/context-capsule-validation.ts +416 -0
  27. package/src/core/context-capsule-verification.ts +218 -0
  28. package/src/core/context-capsule.ts +439 -0
  29. package/src/core/context-compiler.ts +513 -0
  30. package/src/core/context-evidence-metadata.ts +33 -0
  31. package/src/core/context-evidence.ts +495 -0
  32. package/src/core/context-facets.ts +163 -0
  33. package/src/core/context-guidance.ts +69 -0
  34. package/src/core/context-scope.ts +32 -0
  35. package/src/core/context-verifier-canonical.ts +90 -0
  36. package/src/core/context-verifier-input.ts +66 -0
  37. package/src/core/context-verifier.ts +447 -0
  38. package/src/core/job-manager.ts +19 -0
  39. package/src/core/mutation-generations.ts +33 -0
  40. package/src/core/sections.ts +63 -0
  41. package/src/llm/cache.ts +13 -3
  42. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  43. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  44. package/src/mcp/context.ts +161 -0
  45. package/src/mcp/http-security.ts +477 -0
  46. package/src/mcp/http-session.ts +272 -0
  47. package/src/mcp/http-transport.ts +370 -0
  48. package/src/mcp/resources/index.ts +141 -134
  49. package/src/mcp/server.ts +28 -82
  50. package/src/mcp/tools/add-collection.ts +3 -1
  51. package/src/mcp/tools/capture.ts +3 -0
  52. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  53. package/src/mcp/tools/context.ts +230 -0
  54. package/src/mcp/tools/embed.ts +62 -52
  55. package/src/mcp/tools/index-cmd.ts +88 -74
  56. package/src/mcp/tools/index.ts +49 -2
  57. package/src/mcp/tools/remove-collection.ts +2 -0
  58. package/src/mcp/tools/status.ts +11 -0
  59. package/src/mcp/tools/sync.ts +16 -14
  60. package/src/mcp/tools/workspace-write.ts +7 -3
  61. package/src/pipeline/chunk-lookup.ts +33 -0
  62. package/src/pipeline/hybrid.ts +79 -57
  63. package/src/pipeline/types.ts +14 -0
  64. package/src/sdk/client.ts +68 -6
  65. package/src/sdk/index.ts +21 -0
  66. package/src/sdk/types.ts +24 -0
  67. package/src/serve/background-runtime.ts +12 -211
  68. package/src/serve/context-capsule.ts +136 -0
  69. package/src/serve/context.ts +10 -1
  70. package/src/serve/embed-scheduler.ts +74 -43
  71. package/src/serve/index.ts +9 -0
  72. package/src/serve/jobs.ts +78 -80
  73. package/src/serve/public/components/HealthCenter.tsx +74 -1
  74. package/src/serve/public/globals.built.css +1 -1
  75. package/src/serve/public/pages/Dashboard.tsx +1 -0
  76. package/src/serve/resident-admission.ts +159 -0
  77. package/src/serve/resident-background-work.ts +39 -0
  78. package/src/serve/resident-request.ts +55 -0
  79. package/src/serve/resident-runtime.ts +490 -0
  80. package/src/serve/resident-status.ts +96 -0
  81. package/src/serve/routes/api.ts +265 -167
  82. package/src/serve/routes/mcp.ts +69 -0
  83. package/src/serve/server.ts +212 -35
  84. package/src/serve/status-model.ts +51 -0
  85. package/src/serve/status.ts +5 -0
  86. package/src/store/sqlite/adapter.ts +64 -29
@@ -0,0 +1,145 @@
1
+ /** Closed wire contracts shared by Context Capsule REST and MCP surfaces. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { ContextCapsuleBuildInput } from "./context-runtime-types";
6
+
7
+ import { ContextCapsuleContractError } from "../core/context-capsule";
8
+
9
+ const queryModeSchema = z
10
+ .object({
11
+ mode: z.enum(["term", "intent", "hyde"]),
12
+ text: z.string(),
13
+ })
14
+ .strict();
15
+
16
+ const stringList = z.array(z.string());
17
+ const positiveInteger = z.number().int().positive();
18
+ const nonnegativeInteger = z.number().int().nonnegative();
19
+
20
+ /** Host-owned index identity is deliberately absent from this public input. */
21
+ export const contextBuildSurfaceSchema = z
22
+ .object({
23
+ goal: z.string(),
24
+ query: z.string().optional(),
25
+ collections: stringList.optional(),
26
+ uriPrefix: z.string().nullable().optional(),
27
+ queryModes: z.array(queryModeSchema).optional(),
28
+ tagsAll: stringList.optional(),
29
+ tagsAny: stringList.optional(),
30
+ categories: stringList.optional(),
31
+ author: z.string().optional(),
32
+ lang: z.string().optional(),
33
+ since: z.string().optional(),
34
+ until: z.string().optional(),
35
+ graph: z.boolean().optional(),
36
+ limit: positiveInteger.optional(),
37
+ candidateLimit: positiveInteger.optional(),
38
+ budgetTokens: positiveInteger,
39
+ budgetBytes: positiveInteger.optional(),
40
+ safetyMarginTokens: nonnegativeInteger.optional(),
41
+ safetyMarginBytes: nonnegativeInteger.optional(),
42
+ depthPolicy: z.enum(["fast", "balanced", "thorough"]).optional(),
43
+ format: z.enum(["json", "md"]).optional(),
44
+ })
45
+ .strict();
46
+
47
+ export const contextVerifySurfaceSchema = z
48
+ .object({
49
+ capsule: z.record(z.string(), z.unknown()),
50
+ format: z.enum(["json", "md"]).optional(),
51
+ })
52
+ .strict();
53
+
54
+ export type ContextSurfaceFormat = "json" | "md";
55
+
56
+ export interface ParsedContextBuildSurfaceInput {
57
+ input: ContextCapsuleBuildInput;
58
+ format: ContextSurfaceFormat;
59
+ }
60
+
61
+ export interface ParsedContextVerifySurfaceInput {
62
+ capsule: Record<string, unknown>;
63
+ format: ContextSurfaceFormat;
64
+ }
65
+
66
+ const CONTEXT_SURFACE_ERROR_MESSAGES = {
67
+ capsule_mutated_during_verify:
68
+ "The Context Capsule changed during verification.",
69
+ chunk_coordinate_mismatch:
70
+ "Indexed evidence coordinates do not match the source.",
71
+ chunk_load_failed: "Context Capsule evidence chunks could not be loaded.",
72
+ collection_load_failed: "Context Capsule collections could not be loaded.",
73
+ content_load_failed: "Context Capsule source content could not be loaded.",
74
+ context_changed_during_compile:
75
+ "Configured context changed during compilation.",
76
+ context_changed_during_verify:
77
+ "Configured context changed during verification.",
78
+ context_load_failed: "Configured context could not be loaded.",
79
+ document_load_failed: "Context Capsule documents could not be loaded.",
80
+ identity_mismatch: "The Context Capsule identity does not match its content.",
81
+ index_changed_during_compile: "The index changed during compilation.",
82
+ index_changed_during_verify: "The index changed during verification.",
83
+ index_snapshot_failed: "The index snapshot could not be loaded.",
84
+ index_snapshot_mismatch:
85
+ "Indexed evidence does not match the captured snapshot.",
86
+ invalid_budget: "The Context Capsule budget is invalid.",
87
+ invalid_filter: "A Context Capsule filter is invalid.",
88
+ invalid_goal: "The Context Capsule goal is invalid.",
89
+ invalid_input: "The Context Capsule request is invalid.",
90
+ invalid_uri: "The Context Capsule URI is invalid.",
91
+ no_evidence: "No in-scope evidence was available for the Context Capsule.",
92
+ retrieval_failed: "Context Capsule retrieval failed.",
93
+ runtime_error: "The Context Capsule request failed.",
94
+ stored_provenance_mismatch:
95
+ "Stored evidence provenance does not match the source.",
96
+ tokenizer_unavailable: "The required tokenizer is unavailable.",
97
+ } as const;
98
+
99
+ export type ContextSurfaceErrorCode =
100
+ keyof typeof CONTEXT_SURFACE_ERROR_MESSAGES;
101
+
102
+ const invalidInput = (error: z.ZodError): ContextCapsuleContractError =>
103
+ new ContextCapsuleContractError(
104
+ "invalid_input",
105
+ error.issues
106
+ .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
107
+ .join("; ")
108
+ );
109
+
110
+ export const parseContextBuildSurfaceInput = (
111
+ value: unknown,
112
+ indexName: string
113
+ ): ParsedContextBuildSurfaceInput => {
114
+ const parsed = contextBuildSurfaceSchema.safeParse(value);
115
+ if (!parsed.success) throw invalidInput(parsed.error);
116
+ const { format = "json", ...input } = parsed.data;
117
+ return { input: { ...input, indexName }, format };
118
+ };
119
+
120
+ export const parseContextVerifySurfaceInput = (
121
+ value: unknown
122
+ ): ParsedContextVerifySurfaceInput => {
123
+ const parsed = contextVerifySurfaceSchema.safeParse(value);
124
+ if (!parsed.success) throw invalidInput(parsed.error);
125
+ return {
126
+ capsule: parsed.data.capsule,
127
+ format: parsed.data.format ?? "json",
128
+ };
129
+ };
130
+
131
+ export const contextSurfaceError = (
132
+ error: unknown
133
+ ): { code: ContextSurfaceErrorCode; message: string } => {
134
+ const candidate =
135
+ error !== null &&
136
+ typeof error === "object" &&
137
+ "code" in error &&
138
+ typeof error.code === "string"
139
+ ? error.code
140
+ : "runtime_error";
141
+ const code = Object.hasOwn(CONTEXT_SURFACE_ERROR_MESSAGES, candidate)
142
+ ? (candidate as ContextSurfaceErrorCode)
143
+ : "runtime_error";
144
+ return { code, message: CONTEXT_SURFACE_ERROR_MESSAGES[code] };
145
+ };
@@ -0,0 +1,149 @@
1
+ /** Context Capsule build command over the shared application runtime. */
2
+
3
+ import type { ContextCapsuleBuildInput } from "../../app/context-runtime";
4
+ import type { EmbeddingPort, RerankPort } from "../../llm/types";
5
+ import type { VectorIndexPort } from "../../store/vector";
6
+
7
+ import { formatContextCapsuleMarkdown } from "../../app/context-format";
8
+ import {
9
+ buildContextCapsule,
10
+ canonicalBuiltContextCapsuleJson,
11
+ validateContextCapsuleBuildInput,
12
+ } from "../../app/context-runtime";
13
+ import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
14
+ import { resolveDownloadPolicy } from "../../llm/policy";
15
+ import { resolveModelUri } from "../../llm/registry";
16
+ import { createVectorIndexPort } from "../../store/vector";
17
+ import { CliError } from "../errors";
18
+ import { getGlobals } from "../program";
19
+ import {
20
+ createProgressRenderer,
21
+ createThrottledProgressRenderer,
22
+ } from "../progress";
23
+ import { initStore } from "./shared";
24
+
25
+ export interface ContextBuildCommandOptions extends Omit<
26
+ ContextCapsuleBuildInput,
27
+ "goal"
28
+ > {
29
+ configPath?: string;
30
+ format: "json" | "md";
31
+ }
32
+
33
+ const validationCodes = new Set([
34
+ "identity_mismatch",
35
+ "invalid_budget",
36
+ "invalid_filter",
37
+ "invalid_goal",
38
+ "invalid_input",
39
+ "invalid_uri",
40
+ ]);
41
+
42
+ export const contextCliError = (error: unknown): CliError => {
43
+ const contextCode =
44
+ error !== null &&
45
+ typeof error === "object" &&
46
+ "code" in error &&
47
+ typeof error.code === "string"
48
+ ? error.code
49
+ : "runtime_error";
50
+ return new CliError(
51
+ validationCodes.has(contextCode) ? "VALIDATION" : "RUNTIME",
52
+ error instanceof Error ? error.message : String(error),
53
+ { details: { contextCode } }
54
+ );
55
+ };
56
+
57
+ // oxlint-disable-next-line max-lines-per-function -- owns one bounded model/store lifecycle
58
+ export const contextBuild = async (
59
+ goal: string,
60
+ options: ContextBuildCommandOptions
61
+ ): Promise<string> => {
62
+ try {
63
+ validateContextCapsuleBuildInput({ goal, ...options }, options.indexName);
64
+ } catch (error) {
65
+ throw contextCliError(error);
66
+ }
67
+ const initResult = await initStore({
68
+ configPath: options.configPath,
69
+ indexName: options.indexName,
70
+ syncConfig: true,
71
+ });
72
+ if (!initResult.ok) {
73
+ throw new CliError("RUNTIME", initResult.error);
74
+ }
75
+ const { config, store } = initResult;
76
+ const llm = new LlmAdapter(config);
77
+ let embedPort: EmbeddingPort | null = null;
78
+ let rerankPort: RerankPort | null = null;
79
+ let vectorIndex: VectorIndexPort | null = null;
80
+ try {
81
+ validateContextCapsuleBuildInput(
82
+ { goal, ...options },
83
+ options.indexName,
84
+ config.collections.map((collection) => collection.name)
85
+ );
86
+ if (options.depthPolicy !== "fast") {
87
+ const globals = getGlobals();
88
+ const policy = resolveDownloadPolicy(process.env, {
89
+ offline: globals.offline,
90
+ });
91
+ const showProgress = process.stderr.isTTY && !globals.quiet;
92
+ const progress = showProgress
93
+ ? createThrottledProgressRenderer(createProgressRenderer())
94
+ : undefined;
95
+ const collection =
96
+ options.collections?.length === 1 ? options.collections[0] : undefined;
97
+ const embedUri = resolveModelUri(config, "embed", undefined, collection);
98
+ const embedResult = await llm.createEmbeddingPort(embedUri, {
99
+ policy,
100
+ onProgress: progress ? (value) => progress("embed", value) : undefined,
101
+ });
102
+ if (embedResult.ok) {
103
+ embedPort = embedResult.value;
104
+ const initialized = await embedPort.init();
105
+ if (initialized.ok) {
106
+ const vectorResult = await createVectorIndexPort(store.getRawDb(), {
107
+ model: embedUri,
108
+ dimensions: embedPort.dimensions(),
109
+ });
110
+ if (vectorResult.ok) vectorIndex = vectorResult.value;
111
+ }
112
+ }
113
+ const rerankUri = resolveModelUri(
114
+ config,
115
+ "rerank",
116
+ undefined,
117
+ collection
118
+ );
119
+ const rerankResult = await llm.createRerankPort(rerankUri, {
120
+ policy,
121
+ onProgress: progress ? (value) => progress("rerank", value) : undefined,
122
+ });
123
+ if (rerankResult.ok) rerankPort = rerankResult.value;
124
+ if (showProgress && progress) process.stderr.write("\n");
125
+ }
126
+ const capsule = await buildContextCapsule(
127
+ { goal, ...options },
128
+ {
129
+ store,
130
+ config,
131
+ indexName: options.indexName,
132
+ vectorIndex,
133
+ embedPort,
134
+ rerankPort,
135
+ }
136
+ );
137
+ return options.format === "md"
138
+ ? formatContextCapsuleMarkdown(capsule)
139
+ : canonicalBuiltContextCapsuleJson(capsule);
140
+ } catch (error) {
141
+ if (error instanceof CliError) throw error;
142
+ throw contextCliError(error);
143
+ } finally {
144
+ await embedPort?.dispose();
145
+ await rerankPort?.dispose();
146
+ await llm.dispose();
147
+ await store.close();
148
+ }
149
+ };
@@ -0,0 +1,90 @@
1
+ /** Context Capsule verification command over the shared application runtime. */
2
+
3
+ import { formatContextCapsuleVerificationMarkdown } from "../../app/context-format";
4
+ import {
5
+ canonicalVerifiedContextCapsuleJson,
6
+ verifyContextCapsuleRuntime,
7
+ } from "../../app/context-runtime";
8
+ import { canonicalizeIndexName } from "../../app/index-name";
9
+ import { parseCanonicalContextCapsuleForVerification } from "../../core/context-verifier";
10
+ import { CliError } from "../errors";
11
+ import { contextCliError } from "./context-build";
12
+ import { initStore } from "./shared";
13
+
14
+ export interface ContextVerifyCommandOptions {
15
+ configPath?: string;
16
+ indexName?: string;
17
+ format: "json" | "md";
18
+ }
19
+
20
+ const readCapsule = async (source: string): Promise<unknown> => {
21
+ let raw: string;
22
+ try {
23
+ raw =
24
+ source === "-" ? await Bun.stdin.text() : await Bun.file(source).text();
25
+ } catch (error) {
26
+ throw new CliError(
27
+ "RUNTIME",
28
+ `Failed to read Context Capsule: ${
29
+ error instanceof Error ? error.message : String(error)
30
+ }`
31
+ );
32
+ }
33
+ try {
34
+ return JSON.parse(raw) as unknown;
35
+ } catch (error) {
36
+ throw new CliError("VALIDATION", "Context Capsule must be valid JSON", {
37
+ details: {
38
+ contextCode: "invalid_input",
39
+ cause: error instanceof Error ? error.message : String(error),
40
+ },
41
+ });
42
+ }
43
+ };
44
+
45
+ export const contextVerify = async (
46
+ source: string,
47
+ options: ContextVerifyCommandOptions
48
+ ): Promise<string> => {
49
+ const input = await readCapsule(source);
50
+ let capsule: ReturnType<typeof parseCanonicalContextCapsuleForVerification>;
51
+ try {
52
+ capsule = parseCanonicalContextCapsuleForVerification(input);
53
+ } catch (error) {
54
+ throw contextCliError(error);
55
+ }
56
+ if (
57
+ options.indexName !== undefined &&
58
+ canonicalizeIndexName(options.indexName) !== capsule.scope.indexName
59
+ ) {
60
+ throw new CliError(
61
+ "VALIDATION",
62
+ `Context Capsule index ${capsule.scope.indexName} does not match --index ${options.indexName}`,
63
+ { details: { contextCode: "invalid_filter" } }
64
+ );
65
+ }
66
+ const initResult = await initStore({
67
+ configPath: options.configPath,
68
+ indexName: capsule.scope.indexName,
69
+ syncConfig: true,
70
+ });
71
+ if (!initResult.ok) {
72
+ throw new CliError("RUNTIME", initResult.error);
73
+ }
74
+ const { config, store } = initResult;
75
+ try {
76
+ const receipt = await verifyContextCapsuleRuntime(capsule, {
77
+ store,
78
+ config,
79
+ indexName: capsule.scope.indexName,
80
+ });
81
+ return options.format === "md"
82
+ ? formatContextCapsuleVerificationMarkdown(receipt)
83
+ : canonicalVerifiedContextCapsuleJson(receipt);
84
+ } catch (error) {
85
+ if (error instanceof CliError) throw error;
86
+ throw contextCliError(error);
87
+ } finally {
88
+ await store.close();
89
+ }
90
+ };
@@ -1,9 +1,18 @@
1
1
  import type { CollectionSyncResult } from "../../ingestion";
2
+ import type { HttpGatewayOverrides } from "../../mcp/http-security";
2
3
  import type { BackgroundRuntimeResult } from "../../serve/background-runtime";
4
+ import type { ResidentRuntime } from "../../serve/resident-runtime";
3
5
 
6
+ import {
7
+ DEFAULT_HTTP_GATEWAY_PORT,
8
+ isHttpGatewayLoopbackBind,
9
+ resolveHttpGatewayConfig,
10
+ } from "../../mcp/http-security";
4
11
  import { startBackgroundRuntime } from "../../serve/background-runtime";
12
+ import { handleResidentStatus, handleStatus } from "../../serve/routes/api";
13
+ import { createMcpHttpGateway } from "../../serve/routes/mcp";
5
14
 
6
- export interface DaemonOptions {
15
+ export interface DaemonOptions extends HttpGatewayOverrides {
7
16
  configPath?: string;
8
17
  index?: string;
9
18
  offline?: boolean;
@@ -24,6 +33,8 @@ type DaemonLogger = {
24
33
 
25
34
  type DaemonDeps = {
26
35
  startBackgroundRuntime?: typeof startBackgroundRuntime;
36
+ createMcpHttpGateway?: typeof createMcpHttpGateway;
37
+ serve?: typeof Bun.serve;
27
38
  logger?: DaemonLogger;
28
39
  };
29
40
 
@@ -31,6 +42,18 @@ function formatCollectionSyncSummary(result: CollectionSyncResult): string {
31
42
  return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
32
43
  }
33
44
 
45
+ export function handleDaemonAppStatus(
46
+ runtime: ResidentRuntime,
47
+ host: string
48
+ ): Promise<Response> | Response {
49
+ if (!isHttpGatewayLoopbackBind(host)) {
50
+ return new Response(null, { status: 404 });
51
+ }
52
+ return handleStatus(runtime.ctxHolder.current, {
53
+ getResidentStatus: () => runtime.getStatus(),
54
+ });
55
+ }
56
+
34
57
  function createSignalPromise(
35
58
  signal: AbortSignal | undefined,
36
59
  logger: DaemonLogger,
@@ -79,6 +102,7 @@ export async function daemon(
79
102
  const runtimeResult: BackgroundRuntimeResult = await (
80
103
  deps.startBackgroundRuntime ?? startBackgroundRuntime
81
104
  )({
105
+ mode: "daemon",
82
106
  configPath: options.configPath,
83
107
  index: options.index,
84
108
  requireCollections: true,
@@ -108,11 +132,52 @@ export async function daemon(
108
132
  }
109
133
 
110
134
  const { runtime } = runtimeResult;
135
+ const gatewayConfig = resolveHttpGatewayConfig(runtime.config.gateway, {
136
+ host: options.host,
137
+ port: options.port ?? DEFAULT_HTTP_GATEWAY_PORT,
138
+ tokenFile: options.tokenFile,
139
+ allowedHosts: options.allowedHosts,
140
+ allowedOrigins: options.allowedOrigins,
141
+ enableWrite: options.enableWrite,
142
+ });
143
+ let gateway: Awaited<ReturnType<typeof createMcpHttpGateway>> | undefined;
144
+ let server: ReturnType<typeof Bun.serve> | undefined;
111
145
  try {
146
+ gateway = await (deps.createMcpHttpGateway ?? createMcpHttpGateway)(
147
+ runtime as ResidentRuntime,
148
+ gatewayConfig
149
+ );
150
+ server = (deps.serve ?? Bun.serve)({
151
+ port: gatewayConfig.port,
152
+ hostname: gatewayConfig.host,
153
+ development: false,
154
+ routes: {
155
+ "/mcp": gateway.route,
156
+ "/api/status": {
157
+ GET: () =>
158
+ handleDaemonAppStatus(
159
+ runtime as ResidentRuntime,
160
+ gatewayConfig.host
161
+ ),
162
+ },
163
+ "/api/resident/status": {
164
+ GET: () =>
165
+ handleResidentStatus(() =>
166
+ (runtime as ResidentRuntime).getStatus()
167
+ ),
168
+ },
169
+ },
170
+ });
171
+ (runtime as Partial<ResidentRuntime>).setListenerPort?.(
172
+ server.port ?? gatewayConfig.port
173
+ );
112
174
  if (!options.quiet) {
113
175
  logger.log(
114
176
  `GNO daemon started for index "${options.index ?? "default"}" using ${runtime.config.collections.length} collection${runtime.config.collections.length === 1 ? "" : "s"}.`
115
177
  );
178
+ logger.log(
179
+ `MCP gateway listening at http://${gatewayConfig.host}:${server.port}/mcp`
180
+ );
116
181
  const watchState = runtime.watchService.getState();
117
182
  if (watchState.activeCollections.length > 0) {
118
183
  logger.log(`watching: ${watchState.activeCollections.join(", ")}`);
@@ -154,6 +219,8 @@ export async function daemon(
154
219
  error: error instanceof Error ? error.message : String(error),
155
220
  };
156
221
  } finally {
157
- await runtime.dispose();
222
+ await Promise.allSettled([server?.stop(true)]);
223
+ await Promise.allSettled([gateway?.close()]);
224
+ await Promise.allSettled([runtime.dispose()]);
158
225
  }
159
226
  }
@@ -35,6 +35,8 @@ export interface ModelsPullOptions {
35
35
  force?: boolean;
36
36
  /** Progress callback for UI (omit to disable progress) */
37
37
  onProgress?: (type: ModelType, progress: DownloadProgress) => void;
38
+ /** Stop before the next model and suppress subsequent work. */
39
+ signal?: AbortSignal;
38
40
  }
39
41
 
40
42
  export interface ModelPullResult {
@@ -52,6 +54,10 @@ export interface ModelsPullResult {
52
54
  skipped: number;
53
55
  }
54
56
 
57
+ export interface ModelsPullDependencies {
58
+ cache?: Pick<ModelCache, "download" | "getCachedPath" | "isCached">;
59
+ }
60
+
55
61
  // ─────────────────────────────────────────────────────────────────────────────
56
62
  // Implementation
57
63
  // ─────────────────────────────────────────────────────────────────────────────
@@ -87,7 +93,8 @@ function getTypesToPull(options: ModelsPullOptions): ModelType[] {
87
93
  * Execute gno models pull command.
88
94
  */
89
95
  export async function modelsPull(
90
- options: ModelsPullOptions = {}
96
+ options: ModelsPullOptions = {},
97
+ deps: ModelsPullDependencies = {}
91
98
  ): Promise<ModelsPullResult> {
92
99
  // Use provided config, or load from disk (use defaults if not initialized)
93
100
  let config = options.config;
@@ -98,7 +105,7 @@ export async function modelsPull(
98
105
  }
99
106
 
100
107
  const preset = getActivePreset(config);
101
- const cache = new ModelCache(getModelsCachePath());
108
+ const cache = deps.cache ?? new ModelCache(getModelsCachePath());
102
109
  const types = getTypesToPull(options);
103
110
 
104
111
  const results: ModelPullResult[] = [];
@@ -106,6 +113,7 @@ export async function modelsPull(
106
113
  let skipped = 0;
107
114
 
108
115
  for (const type of types) {
116
+ if (options.signal?.aborted) break;
109
117
  const uri =
110
118
  type === "expand" ? (preset.expand ?? preset.gen) : preset[type];
111
119
 
@@ -133,8 +141,10 @@ export async function modelsPull(
133
141
  (progress) => {
134
142
  options.onProgress?.(type, progress);
135
143
  },
136
- options.force
144
+ options.force,
145
+ options.signal
137
146
  );
147
+ if (options.signal?.aborted) break;
138
148
 
139
149
  if (result.ok) {
140
150
  results.push({
@@ -15,6 +15,7 @@ import { buildActivationStatus } from "../../core/activation-status";
15
15
  import { ModelCache } from "../../llm/cache";
16
16
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
17
17
  import { getConnectorVerificationTargets } from "../../serve/connectors";
18
+ import { createStandaloneResidentStatus } from "../../serve/resident-status";
18
19
  import { SqliteAdapter } from "../../store/sqlite/adapter";
19
20
 
20
21
  /**
@@ -277,6 +278,7 @@ export function formatStatus(
277
278
  const s = result.status;
278
279
  return JSON.stringify(
279
280
  {
281
+ resident: createStandaloneResidentStatus("direct-cli"),
280
282
  indexName: s.indexName,
281
283
  configPath: s.configPath,
282
284
  dbPath: s.dbPath,
package/src/cli/detach.ts CHANGED
@@ -20,9 +20,12 @@ import { mkdir, stat, unlink } from "node:fs/promises";
20
20
  // node:path — no Bun path utils.
21
21
  import { dirname, join } from "node:path";
22
22
 
23
+ import type { ResidentStatus } from "../serve/status-model";
24
+
23
25
  import { VERSION, resolveDirs } from "../app/constants";
24
26
  import { toAbsolutePath } from "../config/paths";
25
27
  import { atomicWrite } from "../core/file-ops";
28
+ import { isResidentStatus } from "../serve/resident-status";
26
29
  import { CliError } from "./errors";
27
30
 
28
31
  // ─────────────────────────────────────────────────────────────────────────────
@@ -63,6 +66,7 @@ export interface ProcessStatus {
63
66
  version: string | null;
64
67
  started_at: string | null;
65
68
  uptime_seconds: number | null;
69
+ resident: ResidentStatus | null;
66
70
  pid_file: string;
67
71
  log_file: string;
68
72
  log_size_bytes: number | null;
@@ -424,7 +428,7 @@ export interface SpawnDetachedOptions {
424
428
  * self-contained executable like a single `.mjs` file).
425
429
  */
426
430
  entryScript?: string | null;
427
- /** Optional port to embed in the pid-file payload (serve only). */
431
+ /** Optional resident HTTP port to embed in the pid-file payload. */
428
432
  port?: number | null;
429
433
  /** Working directory for the child. Defaults to `process.cwd()`. */
430
434
  cwd?: string;
@@ -736,6 +740,23 @@ export interface StatusOptions {
736
740
  logFile: string;
737
741
  /** Clock override for deterministic tests. Defaults to `Date.now`. */
738
742
  now?: () => number;
743
+ fetchResidentStatus?: (port: number) => Promise<ResidentStatus | null>;
744
+ }
745
+
746
+ async function fetchResidentStatus(
747
+ port: number
748
+ ): Promise<ResidentStatus | null> {
749
+ try {
750
+ const response = await fetch(
751
+ `http://127.0.0.1:${port}/api/resident/status`,
752
+ { signal: AbortSignal.timeout(500) }
753
+ );
754
+ if (!response.ok) return null;
755
+ const body: unknown = await response.json();
756
+ return isResidentStatus(body) ? body : null;
757
+ } catch {
758
+ return null;
759
+ }
739
760
  }
740
761
 
741
762
  /**
@@ -765,6 +786,7 @@ export async function statusProcess(
765
786
  version: null,
766
787
  started_at: null,
767
788
  uptime_seconds: null,
789
+ resident: null,
768
790
  pid_file: options.pidFile,
769
791
  log_file: options.logFile,
770
792
  log_size_bytes: logSize,
@@ -789,33 +811,28 @@ export async function statusProcess(
789
811
  ? Math.max(0, Math.floor((now() - Date.parse(payload.started_at)) / 1000))
790
812
  : null;
791
813
 
792
- // Schema invariant: a live serve must report a numeric port. If the pid-file
793
- // is missing one somehow, fall back to not-running rather than lying.
794
- const portForRunningServe =
795
- running && effectiveKind === "serve"
796
- ? typeof payload.port === "number"
797
- ? payload.port
798
- : null
799
- : running && effectiveKind === "daemon"
800
- ? null
801
- : (payload.port ?? null);
802
-
803
- const runningFinal =
804
- running && !(effectiveKind === "serve" && portForRunningServe === null);
814
+ // Both resident modes expose an HTTP listener. If a live pid-file is
815
+ // missing its port, fall back to not-running rather than claiming a gateway
816
+ // that clients cannot locate.
817
+ const portForRunningProcess =
818
+ running && typeof payload.port === "number" ? payload.port : null;
819
+ const runningFinal = running && portForRunningProcess !== null;
820
+ const resident =
821
+ runningFinal && portForRunningProcess !== null
822
+ ? await (options.fetchResidentStatus ?? fetchResidentStatus)(
823
+ portForRunningProcess
824
+ )
825
+ : null;
805
826
 
806
827
  return {
807
828
  running: runningFinal,
808
829
  pid: payload.pid,
809
- port:
810
- runningFinal && effectiveKind === "serve"
811
- ? portForRunningServe
812
- : effectiveKind === "daemon"
813
- ? null
814
- : (payload.port ?? null),
830
+ port: runningFinal ? portForRunningProcess : (payload.port ?? null),
815
831
  cmd: effectiveKind,
816
832
  version: payload.version,
817
833
  started_at: payload.started_at,
818
834
  uptime_seconds: runningFinal ? uptimeSeconds : null,
835
+ resident,
819
836
  pid_file: options.pidFile,
820
837
  log_file: options.logFile,
821
838
  log_size_bytes: logSize,