@gmickel/gno 2.5.1 → 2.7.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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,713 @@
1
+ /**
2
+ * gno sessions: discover, init, source, import, status and prune.
3
+ *
4
+ * Thin adapters over the transport-neutral sessions service. Nothing here
5
+ * runs automatically; every import is an explicit invocation bound to one
6
+ * archive config/index pair.
7
+ *
8
+ * @module src/cli/commands/sessions
9
+ */
10
+
11
+ import type { Config } from "../../config/types";
12
+
13
+ import { getIndexDbPath } from "../../app/constants";
14
+ import { getConfigPaths, isInitialized, loadConfig } from "../../config";
15
+ import { acquireCliWriteLease } from "../../core/write-lease";
16
+ import {
17
+ type AutomationContext,
18
+ admitHookTrigger,
19
+ disableAutomation,
20
+ enableAutomation,
21
+ type HookAdmission,
22
+ previewAutomationProfile,
23
+ removeAutomationProfile,
24
+ runAutomationProfile,
25
+ type SessionAutomationChange,
26
+ type SessionAutomationPreview,
27
+ setAutomationProfile,
28
+ } from "../../sessions/automation";
29
+ import { HOOK_ADMISSION_DEADLINE_MS } from "../../sessions/automation-state";
30
+ import {
31
+ formatAutomationRunText,
32
+ formatImportReceiptText,
33
+ formatStatusText,
34
+ } from "../../sessions/format";
35
+ import {
36
+ type SessionPrunePreview,
37
+ SessionsService,
38
+ } from "../../sessions/service";
39
+ import {
40
+ addSessionSource,
41
+ initSessionArchive,
42
+ removeSessionSource,
43
+ } from "../../sessions/setup";
44
+ import {
45
+ SESSION_HARNESSES,
46
+ type SessionAutomationRunResult,
47
+ type SessionHarness,
48
+ type SessionImportReceipt,
49
+ type SessionsDiscovery,
50
+ SessionsError,
51
+ type SessionsStatus,
52
+ SESSIONS_VALIDATION_CODES,
53
+ } from "../../sessions/types";
54
+ import { SqliteAdapter } from "../../store/sqlite/adapter";
55
+ import { CliError } from "../errors";
56
+ import { initStore } from "./shared";
57
+
58
+ export interface SessionsCliContext {
59
+ configPath?: string;
60
+ indexName: string;
61
+ }
62
+
63
+ /** Map a service error onto the CLI error model. */
64
+ export function toCliError(error: unknown): unknown {
65
+ if (!(error instanceof SessionsError)) return error;
66
+ if (error.code === "SESSIONS_BUSY") {
67
+ return new CliError("BUSY", error.message, {
68
+ details: { sessionsCode: error.code },
69
+ });
70
+ }
71
+ return new CliError(
72
+ SESSIONS_VALIDATION_CODES.has(error.code) ? "VALIDATION" : "RUNTIME",
73
+ error.message,
74
+ { details: { sessionsCode: error.code } }
75
+ );
76
+ }
77
+
78
+ async function withCliErrors<T>(run: () => Promise<T>): Promise<T> {
79
+ try {
80
+ return await run();
81
+ } catch (error) {
82
+ throw toCliError(error);
83
+ }
84
+ }
85
+
86
+ function requireArchivePair(context: SessionsCliContext): string {
87
+ if (!context.configPath) {
88
+ throw new CliError(
89
+ "VALIDATION",
90
+ "Session commands need the archive pair explicitly: gno --config <archive.yml> --index <name> sessions ...",
91
+ { details: { sessionsCode: "SESSIONS_NOT_CONFIGURED" } }
92
+ );
93
+ }
94
+ return context.configPath;
95
+ }
96
+
97
+ async function loadArchiveConfig(
98
+ context: SessionsCliContext
99
+ ): Promise<{ config: Config; configPath: string }> {
100
+ const configPath = requireArchivePair(context);
101
+ const loaded = await loadConfig(configPath);
102
+ if (!loaded.ok) {
103
+ throw new CliError("VALIDATION", loaded.error.message, {
104
+ details: { sessionsCode: "SESSIONS_NOT_CONFIGURED" },
105
+ });
106
+ }
107
+ return { config: loaded.value, configPath };
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Commands
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ export function discoverSessions(
115
+ context: SessionsCliContext
116
+ ): Promise<SessionsDiscovery> {
117
+ return withCliErrors(async () => {
118
+ let config: Config | undefined;
119
+ const path = context.configPath ?? getConfigPaths().configFile;
120
+ if (await isInitialized(context.configPath)) {
121
+ const loaded = await loadConfig(path);
122
+ if (loaded.ok) config = loaded.value;
123
+ }
124
+ const service = new SessionsService({
125
+ config: config ?? ({ collections: [] } as unknown as Config),
126
+ configPath: path,
127
+ indexName: context.indexName,
128
+ });
129
+ return service.discover();
130
+ });
131
+ }
132
+
133
+ export function initSessions(
134
+ context: SessionsCliContext,
135
+ options: { archive?: string; collection?: string }
136
+ ): Promise<{
137
+ configPath: string;
138
+ index: string;
139
+ archiveRoot: string;
140
+ collection: string;
141
+ created: boolean;
142
+ }> {
143
+ return withCliErrors(async () => {
144
+ const configPath = requireArchivePair(context);
145
+ if (!options.archive || !options.collection) {
146
+ throw new SessionsError(
147
+ "SESSIONS_DESTINATION_REQUIRED",
148
+ "sessions init needs --archive <dir> and --collection <name>."
149
+ );
150
+ }
151
+ const result = await initSessionArchive({
152
+ configPath,
153
+ indexName: context.indexName,
154
+ archiveRoot: options.archive,
155
+ collection: options.collection,
156
+ });
157
+ return {
158
+ configPath,
159
+ index: context.indexName,
160
+ archiveRoot: result.archiveRoot,
161
+ collection: options.collection,
162
+ created: result.created,
163
+ };
164
+ });
165
+ }
166
+
167
+ function parseProjectMappings(
168
+ values: readonly string[]
169
+ ): Array<{ prefix: string; collection: string }> {
170
+ return values.map((value) => {
171
+ const split = value.lastIndexOf("=");
172
+ const prefix = split > 0 ? value.slice(0, split) : "";
173
+ const collection = split > 0 ? value.slice(split + 1) : "";
174
+ if (!prefix || !collection) {
175
+ throw new CliError(
176
+ "VALIDATION",
177
+ "--project expects <absolute-prefix>=<collection>",
178
+ { details: { sessionsCode: "SESSIONS_INVALID_INPUT" } }
179
+ );
180
+ }
181
+ return { prefix, collection };
182
+ });
183
+ }
184
+
185
+ function parseHarness(value: unknown): SessionHarness | undefined {
186
+ if (value === undefined) return undefined;
187
+ if (
188
+ typeof value === "string" &&
189
+ (SESSION_HARNESSES as readonly string[]).includes(value)
190
+ ) {
191
+ return value as SessionHarness;
192
+ }
193
+ throw new CliError(
194
+ "VALIDATION",
195
+ `Unsupported format "${typeof value === "string" ? value : "?"}". Supported: ${SESSION_HARNESSES.join(", ")}.`,
196
+ { details: { sessionsCode: "SESSIONS_UNSUPPORTED_FORMAT" } }
197
+ );
198
+ }
199
+
200
+ export function addSource(
201
+ context: SessionsCliContext,
202
+ options: {
203
+ id: string;
204
+ harness?: string;
205
+ path?: string;
206
+ collection?: string;
207
+ projects?: string[];
208
+ }
209
+ ): Promise<{ id: string; registered: true }> {
210
+ return withCliErrors(async () => {
211
+ const { configPath } = await loadArchiveConfig(context);
212
+ const harness = parseHarness(options.harness);
213
+ if (!harness || !options.path || !options.collection) {
214
+ throw new SessionsError(
215
+ "SESSIONS_INVALID_INPUT",
216
+ "sessions source add needs --harness, --path and --collection."
217
+ );
218
+ }
219
+ await addSessionSource({
220
+ configPath,
221
+ id: options.id,
222
+ harness,
223
+ path: options.path,
224
+ collection: options.collection,
225
+ projects: parseProjectMappings(options.projects ?? []),
226
+ });
227
+ return { id: options.id, registered: true };
228
+ });
229
+ }
230
+
231
+ export function removeSource(
232
+ context: SessionsCliContext,
233
+ id: string
234
+ ): Promise<{ id: string; removed: true; archiveRetained: true }> {
235
+ return withCliErrors(async () => {
236
+ const { configPath } = await loadArchiveConfig(context);
237
+ await removeSessionSource({ configPath, id });
238
+ return { id, removed: true, archiveRetained: true };
239
+ });
240
+ }
241
+
242
+ function parseLimit(raw: unknown): number | undefined {
243
+ if (raw === undefined) return undefined;
244
+ const value = Number(raw);
245
+ if (!Number.isSafeInteger(value) || value < 1) {
246
+ throw new CliError("VALIDATION", "--limit must be a positive integer.", {
247
+ details: { sessionsCode: "SESSIONS_INVALID_INPUT" },
248
+ });
249
+ }
250
+ return value;
251
+ }
252
+
253
+ export function importSessions(
254
+ context: SessionsCliContext,
255
+ options: {
256
+ paths: string[];
257
+ source?: string;
258
+ collection?: string;
259
+ format?: string;
260
+ dryRun?: boolean;
261
+ limit?: unknown;
262
+ }
263
+ ): Promise<SessionImportReceipt> {
264
+ return withCliErrors(async () => {
265
+ const { config, configPath } = await loadArchiveConfig(context);
266
+ const format = parseHarness(options.format);
267
+ const limit = parseLimit(options.limit);
268
+ const input = {
269
+ sourceId: options.source,
270
+ paths: options.paths,
271
+ collection: options.collection,
272
+ format,
273
+ dryRun: options.dryRun === true,
274
+ limit,
275
+ };
276
+ if (input.dryRun) {
277
+ return new SessionsService({
278
+ config,
279
+ configPath,
280
+ indexName: context.indexName,
281
+ }).import(input, { allowPaths: true });
282
+ }
283
+ const opened = await initStore({
284
+ configPath,
285
+ indexName: context.indexName,
286
+ allowEmptyCollections: true,
287
+ });
288
+ if (!opened.ok) {
289
+ throw new CliError("RUNTIME", opened.error);
290
+ }
291
+ try {
292
+ return await new SessionsService({
293
+ config: opened.config,
294
+ configPath,
295
+ indexName: context.indexName,
296
+ store: opened.store,
297
+ }).import(input, { allowPaths: true });
298
+ } finally {
299
+ await opened.store.close();
300
+ }
301
+ });
302
+ }
303
+
304
+ export function sessionsStatus(
305
+ context: SessionsCliContext
306
+ ): Promise<SessionsStatus> {
307
+ return withCliErrors(async () => {
308
+ const { config, configPath } = await loadArchiveConfig(context);
309
+ return new SessionsService({
310
+ config,
311
+ configPath,
312
+ indexName: context.indexName,
313
+ }).status();
314
+ });
315
+ }
316
+
317
+ export function pruneSessions(
318
+ context: SessionsCliContext,
319
+ options: { source?: string; apply?: boolean }
320
+ ): Promise<SessionPrunePreview> {
321
+ return withCliErrors(async () => {
322
+ if (!options.source) {
323
+ throw new SessionsError(
324
+ "SESSIONS_SELECTION_REQUIRED",
325
+ "sessions prune needs --source <id>."
326
+ );
327
+ }
328
+ const { config, configPath } = await loadArchiveConfig(context);
329
+ if (!options.apply) {
330
+ return new SessionsService({
331
+ config,
332
+ configPath,
333
+ indexName: context.indexName,
334
+ }).prune({ sourceId: options.source, apply: false });
335
+ }
336
+ const opened = await initStore({
337
+ configPath,
338
+ indexName: context.indexName,
339
+ allowEmptyCollections: true,
340
+ });
341
+ if (!opened.ok) throw new CliError("RUNTIME", opened.error);
342
+ try {
343
+ return await new SessionsService({
344
+ config: opened.config,
345
+ configPath,
346
+ indexName: context.indexName,
347
+ store: opened.store,
348
+ }).prune({ sourceId: options.source, apply: true });
349
+ } finally {
350
+ await opened.store.close();
351
+ }
352
+ });
353
+ }
354
+
355
+ // ─────────────────────────────────────────────────────────────────────────────
356
+ // Automation (opt-in hooks and daemon schedules)
357
+ // ─────────────────────────────────────────────────────────────────────────────
358
+
359
+ function automationContext(context: SessionsCliContext): AutomationContext {
360
+ return {
361
+ configPath: requireArchivePair(context),
362
+ indexName: context.indexName,
363
+ };
364
+ }
365
+
366
+ function parseOptionalInt(raw: unknown, flag: string): number | undefined {
367
+ if (raw === undefined) return undefined;
368
+ const value = Number(raw);
369
+ if (!Number.isSafeInteger(value) || value < 0) {
370
+ throw new CliError(
371
+ "VALIDATION",
372
+ `${flag} must be a non-negative integer.`,
373
+ {
374
+ details: { sessionsCode: "SESSIONS_INVALID_INPUT" },
375
+ }
376
+ );
377
+ }
378
+ return value;
379
+ }
380
+
381
+ export function setAutomation(
382
+ context: SessionsCliContext,
383
+ id: string,
384
+ options: {
385
+ sources: string[];
386
+ cadence?: string;
387
+ limit?: unknown;
388
+ retries?: unknown;
389
+ }
390
+ ): Promise<SessionAutomationPreview> {
391
+ return withCliErrors(() =>
392
+ setAutomationProfile(automationContext(context), {
393
+ id,
394
+ sources: options.sources,
395
+ cadence: options.cadence,
396
+ limit: parseOptionalInt(options.limit, "--limit"),
397
+ retries: parseOptionalInt(options.retries, "--retries"),
398
+ })
399
+ );
400
+ }
401
+
402
+ export function previewAutomation(
403
+ context: SessionsCliContext,
404
+ id: string,
405
+ options: { settings?: string }
406
+ ): Promise<SessionAutomationPreview> {
407
+ return withCliErrors(() =>
408
+ previewAutomationProfile(automationContext(context), id, options)
409
+ );
410
+ }
411
+
412
+ export function enableAutomationCli(
413
+ context: SessionsCliContext,
414
+ id: string,
415
+ options: {
416
+ hook?: string;
417
+ settings?: string;
418
+ schedule?: boolean;
419
+ cadence?: string;
420
+ }
421
+ ): Promise<SessionAutomationPreview> {
422
+ return withCliErrors(() =>
423
+ enableAutomation(automationContext(context), id, {
424
+ ...(options.hook
425
+ ? { hook: { harness: options.hook, settings: options.settings } }
426
+ : {}),
427
+ ...(options.schedule ? { schedule: { cadence: options.cadence } } : {}),
428
+ })
429
+ );
430
+ }
431
+
432
+ export function disableAutomationCli(
433
+ context: SessionsCliContext,
434
+ id: string,
435
+ options: { hook?: boolean; schedule?: boolean }
436
+ ): Promise<SessionAutomationChange> {
437
+ return withCliErrors(() =>
438
+ disableAutomation(automationContext(context), id, {
439
+ ...(options.hook ? { hook: true } : {}),
440
+ ...(options.schedule ? { schedule: true } : {}),
441
+ })
442
+ );
443
+ }
444
+
445
+ export function removeAutomation(
446
+ context: SessionsCliContext,
447
+ id: string
448
+ ): Promise<SessionAutomationChange> {
449
+ return withCliErrors(() =>
450
+ removeAutomationProfile(automationContext(context), id)
451
+ );
452
+ }
453
+
454
+ /** Explicit run-now through the same importer and pending marker as the daemon. */
455
+ export function runAutomation(
456
+ context: SessionsCliContext,
457
+ id: string
458
+ ): Promise<SessionAutomationRunResult> {
459
+ return withCliErrors(async () => {
460
+ const ctx = automationContext(context);
461
+ const { config } = await loadArchiveConfig(context);
462
+ const dbPath = getIndexDbPath(context.indexName);
463
+ // Open without projecting the config: the run syncs only what it needs,
464
+ // so a busy index becomes a recorded `busy` run, not a raw lock error.
465
+ const store = new SqliteAdapter();
466
+ store.setConfigPath(ctx.configPath);
467
+ const opened = await store.open(
468
+ dbPath,
469
+ config.ftsTokenizer,
470
+ config.busyTimeoutMs
471
+ );
472
+ if (!opened.ok) throw new CliError("RUNTIME", opened.error.message);
473
+ try {
474
+ return await runAutomationProfile(
475
+ {
476
+ ...ctx,
477
+ store,
478
+ // Like the daemon: a concurrent writer is a recorded busy run.
479
+ acquireLease: async () => {
480
+ const lease = await acquireCliWriteLease({
481
+ dbPath,
482
+ waitMs: 0,
483
+ noWait: true,
484
+ command: "gno sessions automation run",
485
+ });
486
+ return lease.ok
487
+ ? { ok: true, release: lease.release }
488
+ : { ok: false };
489
+ },
490
+ },
491
+ id,
492
+ { trigger: "manual" }
493
+ );
494
+ } finally {
495
+ await store.close();
496
+ }
497
+ });
498
+ }
499
+
500
+ /** Kill switch for every installed hook: `GNO_SESSIONS_HOOKS=off`. */
501
+ export const SESSIONS_HOOKS_ENV = "GNO_SESSIONS_HOOKS";
502
+ const HOOK_PAYLOAD_MAX_BYTES = 64 * 1024;
503
+
504
+ /** Read the small JSON event a host hook writes on stdin (bounded). */
505
+ export async function readHookPayload(
506
+ stream?: ReadableStream<Uint8Array>
507
+ ): Promise<unknown> {
508
+ if (!stream && process.stdin.isTTY) return null;
509
+ const chunks: Uint8Array[] = [];
510
+ let size = 0;
511
+ const reader = (stream ?? Bun.stdin.stream()).getReader();
512
+ const read = (async () => {
513
+ for (;;) {
514
+ const { done, value } = await reader.read();
515
+ if (done) return true;
516
+ size += value.byteLength;
517
+ if (size > HOOK_PAYLOAD_MAX_BYTES) return false;
518
+ chunks.push(value);
519
+ }
520
+ })();
521
+ const timer = Bun.sleep(HOOK_ADMISSION_DEADLINE_MS / 2).then(() => null);
522
+ const complete = await Promise.race([read, timer]);
523
+ // A host that keeps stdin open must not cost the admission: cleanup
524
+ // failures are ignored.
525
+ await reader.cancel().catch(() => undefined);
526
+ try {
527
+ reader.releaseLock();
528
+ } catch {
529
+ // Still locked by the abandoned read; the process exits right after.
530
+ }
531
+ if (complete === false) return "oversized";
532
+ if (complete === null || size === 0) return null;
533
+ try {
534
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
535
+ } catch {
536
+ return "invalid";
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Host hook entrypoint. Tiny by design: it validates the event, durably
542
+ * marks the profile pending and returns. It never imports, parses sessions
543
+ * or touches the network; its one-line output is content-free.
544
+ */
545
+ export async function runSessionsHook(
546
+ context: SessionsCliContext,
547
+ harness: string,
548
+ options: { profile?: string }
549
+ ): Promise<string> {
550
+ const profileId = options.profile ?? "";
551
+ const killSwitch = process.env[SESSIONS_HOOKS_ENV]?.trim().toLowerCase();
552
+ if (killSwitch === "off" || killSwitch === "0") {
553
+ return `gno sessions hook: skipped (${SESSIONS_HOOKS_ENV}=${killSwitch})`;
554
+ }
555
+ if (harness !== "claude-code" || !profileId) {
556
+ throw new CliError(
557
+ "VALIDATION",
558
+ "gno sessions hook: not accepted (usage: sessions hook claude-code --profile <id>)",
559
+ { details: { sessionsCode: "SESSIONS_UNSUPPORTED_INTEGRATION" } }
560
+ );
561
+ }
562
+ const payload = await readHookPayload();
563
+ if (payload === "invalid" || payload === "oversized") {
564
+ return `gno sessions hook: skipped (profile ${profileId}: ${payload}_event)`;
565
+ }
566
+ let admission: HookAdmission;
567
+ try {
568
+ admission = await admitHookTrigger(automationContext(context), {
569
+ harness: "claude-code",
570
+ profileId,
571
+ payload,
572
+ });
573
+ } catch (error) {
574
+ const code =
575
+ error instanceof SessionsError ? error.code : "SESSIONS_RUNTIME_FAILURE";
576
+ const reason =
577
+ code === "SESSIONS_BUSY" ? "admission_deadline" : code.toLowerCase();
578
+ throw new CliError(
579
+ "RUNTIME",
580
+ `gno sessions hook: not accepted (profile ${profileId}: ${reason}); nothing was archived`,
581
+ { details: { sessionsCode: code } }
582
+ );
583
+ }
584
+ if (admission.outcome === "skipped") {
585
+ return `gno sessions hook: skipped (profile ${profileId}: ${admission.reason})`;
586
+ }
587
+ const next =
588
+ admission.daemon === "running"
589
+ ? "the daemon imports it on its next tick"
590
+ : `not running: no daemon; run gno sessions automation run ${profileId}`;
591
+ return `gno sessions hook: accepted (profile ${profileId} pending, not yet archived; ${next})`;
592
+ }
593
+
594
+ export function formatAutomationPreview(
595
+ preview: SessionAutomationPreview,
596
+ asJson: boolean
597
+ ): string {
598
+ if (asJson) return json(preview);
599
+ const lines = [
600
+ `Automation profile ${preview.profileId} (archive ${preview.archiveRoot}, index ${preview.index}, config ${preview.configPath})`,
601
+ "Sources:",
602
+ ...preview.sources.map((source) => {
603
+ const projects = source.projects
604
+ .map((mapping) => `${mapping.prefix}=${mapping.collection}`)
605
+ .join(", ");
606
+ return source.harness
607
+ ? `- ${source.id} (${source.harness}) ${source.path}${source.available ? "" : " [UNAVAILABLE]"} -> ${source.collection}${projects ? ` (projects: ${projects})` : ""}`
608
+ : `- ${source.id}: NOT REGISTERED`;
609
+ }),
610
+ `Destination collections: ${preview.collections.join(", ") || "(none)"}`,
611
+ `Hook ${preview.hook.harness}: ${preview.hook.enabled ? "on" : "off"}; settings ${preview.hook.settings} (entry ${preview.hook.installed === null ? "unreadable" : preview.hook.installed ? "installed" : "not installed"})`,
612
+ ` command: ${preview.hook.command}`,
613
+ `Schedule: ${preview.schedule.enabled ? `on, every ${preview.schedule.cadence}` : `off${preview.schedule.cadence ? ` (cadence ${preview.schedule.cadence})` : ""}`}; minimum ${preview.schedule.minimum}`,
614
+ `Budget: ${preview.limit} changed units per source per run; ${preview.retries} automatic retries`,
615
+ `Daemon: ${preview.daemon.state === "running" ? "running" : "not running: no daemon"} (${preview.daemon.command})`,
616
+ ...preview.notes.map((note) =>
617
+ note.startsWith("warning: ") ? note : `note: ${note}`
618
+ ),
619
+ ];
620
+ return lines.join("\n");
621
+ }
622
+
623
+ export function formatAutomationChange(
624
+ change: SessionAutomationChange,
625
+ asJson: boolean
626
+ ): string {
627
+ if (asJson) return json(change);
628
+ const lines = [
629
+ change.removed
630
+ ? `Automation profile ${change.profileId} removed; archived sessions were retained.`
631
+ : `Automation profile ${change.profileId} paused.`,
632
+ ];
633
+ if (change.hook) {
634
+ lines.push(
635
+ `hook: off (${change.hook.entriesRemoved} owned settings entr${change.hook.entriesRemoved === 1 ? "y" : "ies"} removed)`
636
+ );
637
+ }
638
+ if (change.schedule) lines.push("schedule: off");
639
+ if (change.pendingCleared) lines.push("pending work: cleared");
640
+ if (change.running) {
641
+ lines.push(
642
+ `a run started at ${change.running.startedAt} finishes its bounded batch; nothing new starts`
643
+ );
644
+ }
645
+ for (const warning of change.warnings) lines.push(`warning: ${warning}`);
646
+ return lines.join("\n");
647
+ }
648
+
649
+ export function formatAutomationRun(
650
+ result: SessionAutomationRunResult,
651
+ asJson: boolean
652
+ ): string {
653
+ return asJson ? json(result) : formatAutomationRunText(result);
654
+ }
655
+
656
+ // ─────────────────────────────────────────────────────────────────────────────
657
+ // Formatting
658
+ // ─────────────────────────────────────────────────────────────────────────────
659
+
660
+ const json = (value: unknown): string => JSON.stringify(value, null, 2);
661
+
662
+ export function formatDiscovery(
663
+ result: SessionsDiscovery,
664
+ asJson: boolean
665
+ ): string {
666
+ if (asJson) return json(result);
667
+ if (result.candidates.length === 0) {
668
+ return "No supported local session sources found (Codex, Claude Code, OpenClaw, Hermes).";
669
+ }
670
+ const lines = ["Supported local session sources (nothing imported):", ""];
671
+ for (const candidate of result.candidates) {
672
+ lines.push(
673
+ `- ${candidate.harness}: ${candidate.path}`,
674
+ ` units: ${candidate.units}${candidate.truncated ? "+" : ""}, ${(candidate.bytes / 1024 / 1024).toFixed(1)} MiB, versions: ${candidate.formatVersions.join(", ") || "unknown"}${candidate.registeredAs ? `, registered as ${candidate.registeredAs}` : ""}`
675
+ );
676
+ }
677
+ lines.push(
678
+ "",
679
+ "Import explicitly: gno --config <archive.yml> --index <name> sessions import --source <id>"
680
+ );
681
+ for (const warning of result.warnings) lines.push(`warning: ${warning}`);
682
+ return lines.join("\n");
683
+ }
684
+
685
+ export function formatImportReceipt(
686
+ receipt: SessionImportReceipt,
687
+ asJson: boolean
688
+ ): string {
689
+ return asJson ? json(receipt) : formatImportReceiptText(receipt);
690
+ }
691
+
692
+ export function formatStatus(status: SessionsStatus, asJson: boolean): string {
693
+ return asJson ? json(status) : formatStatusText(status);
694
+ }
695
+
696
+ export function formatPrune(
697
+ result: SessionPrunePreview,
698
+ asJson: boolean
699
+ ): string {
700
+ if (asJson) return json(result);
701
+ if (result.units.length === 0) {
702
+ return `Nothing to prune for ${result.sourceId}: every archived unit still has a source.`;
703
+ }
704
+ const lines = [
705
+ `${result.applied ? "Pruned" : "Would prune"} ${result.archiveFiles} archive files from ${result.units.length} units whose source is gone (${result.sourceId}):`,
706
+ ...result.units
707
+ .slice(0, 50)
708
+ .map((unit) => `- ${unit.locator} (${unit.threads} threads)`),
709
+ ];
710
+ if (!result.applied)
711
+ lines.push("", "Re-run with --apply to delete these archive files.");
712
+ return lines.join("\n");
713
+ }