@gmickel/gno 2.5.1 → 2.6.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.
- package/README.md +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +75 -1
- package/assets/skill/cli-reference.md +123 -0
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +52 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +347 -24
- package/spec/mcp.md +198 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/program.ts +388 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/index.ts +9 -0
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/index.ts +66 -0
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +4 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Dashboard.tsx +10 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +205 -1
- package/src/serve/session-automation.ts +146 -0
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +54 -15
- package/src/store/sqlite/scoped-index.ts +9 -0
- 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
|
+
}
|