@groundtrack/hooks 0.1.26 → 0.1.27
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 +25 -0
- package/dist/groundtrack.js +6 -1
- package/dist/session-import/canonical.js +1 -1
- package/dist/session-import/cli.d.ts +1 -2
- package/dist/session-import/cli.js +36 -44
- package/dist/session-import/codex-pages.d.ts +22 -0
- package/dist/session-import/codex-pages.js +184 -0
- package/dist/session-import/codex.js +3 -0
- package/dist/session-import/discovery.d.ts +18 -1
- package/dist/session-import/discovery.js +50 -4
- package/dist/session-import/receipts.d.ts +1 -1
- package/dist/session-import/sync.d.ts +32 -0
- package/dist/session-import/sync.js +55 -0
- package/dist/session-import/types.d.ts +3 -0
- package/dist/session-import/watch-lock.d.ts +17 -0
- package/dist/session-import/watch-lock.js +105 -0
- package/dist/session-import/watch-state.d.ts +47 -0
- package/dist/session-import/watch-state.js +128 -0
- package/dist/session-import/watch.d.ts +38 -0
- package/dist/session-import/watch.js +402 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { environment } from "../setup/agents.js";
|
|
4
|
+
import { parseImportArguments } from "./cli.js";
|
|
5
|
+
import { groupCodexPages } from "./codex-pages.js";
|
|
6
|
+
import { discoverSessions, repositoryIdentity, statSessionFiles, } from "./discovery.js";
|
|
7
|
+
import { sessionImportFormat } from "./formats.js";
|
|
8
|
+
import { readReceipts } from "./receipts.js";
|
|
9
|
+
import { prepareSession, uploadPreparedSession } from "./sync.js";
|
|
10
|
+
import { acquireWatchLock } from "./watch-lock.js";
|
|
11
|
+
import { deserializeMetadata, readWatchState, serializeMetadata, watchStatePath, writeWatchState, } from "./watch-state.js";
|
|
12
|
+
export const WATCH_USAGE = `Usage:
|
|
13
|
+
groundtrack watch --once [--agent codex|claude] [--repo PATH | --all-repos] [--since DATE]
|
|
14
|
+
[--include-subagents] [--include-unscoped]
|
|
15
|
+
[--visibility private|organization] [--dry-run] [--yes] [--json]
|
|
16
|
+
|
|
17
|
+
Watch options:
|
|
18
|
+
--once Run one scan and exit (required for now)
|
|
19
|
+
--agent VALUE codex or claude
|
|
20
|
+
--repo PATH Watch sessions for this Git repository (default: current repository)
|
|
21
|
+
--all-repos Watch sessions associated with any Git repository
|
|
22
|
+
--include-unscoped With --all-repos, also watch sessions outside a Git repository
|
|
23
|
+
--since DATE Only sessions at or after an ISO timestamp or local YYYY-MM-DD
|
|
24
|
+
--include-subagents Include subagent, forked-agent, and guardian sessions
|
|
25
|
+
--visibility VALUE private (default) or organization
|
|
26
|
+
--api-key KEY API key; prefer GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY
|
|
27
|
+
--receipt-scope VALUE Stable receipt scope from create_session_import_key
|
|
28
|
+
--url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)
|
|
29
|
+
--dry-run Scan and plan without uploading or writing cursor state
|
|
30
|
+
--yes, -y Upload completed sessions without prompting
|
|
31
|
+
--json Emit exactly one machine-readable result object
|
|
32
|
+
`;
|
|
33
|
+
export function parseWatchArguments(argv) {
|
|
34
|
+
const once = argv.includes("--once");
|
|
35
|
+
const args = parseImportArguments(argv.filter((arg) => arg !== "--once"));
|
|
36
|
+
if (args.help)
|
|
37
|
+
return { ...args, once: true };
|
|
38
|
+
if (!once)
|
|
39
|
+
throw new Error("The watch loop is not implemented yet. Use groundtrack watch --once.");
|
|
40
|
+
return { ...args, once: true };
|
|
41
|
+
}
|
|
42
|
+
function errorMessage(error) {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
function skippedCounts(skipped) {
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const item of skipped)
|
|
48
|
+
result[item.reason] = (result[item.reason] ?? 0) + 1;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function fingerprintPages(pages, stats) {
|
|
52
|
+
const hash = createHash("sha256");
|
|
53
|
+
for (const path of [...pages].sort()) {
|
|
54
|
+
const stat = stats.get(path);
|
|
55
|
+
hash.update(`${path}\0${stat?.size ?? -1}\0${stat?.mtimeMs ?? -1}\n`);
|
|
56
|
+
}
|
|
57
|
+
return hash.digest("hex");
|
|
58
|
+
}
|
|
59
|
+
function diffWatchFiles(state, stats) {
|
|
60
|
+
let added = 0;
|
|
61
|
+
let changed = 0;
|
|
62
|
+
let removed = 0;
|
|
63
|
+
for (const [path, stat] of stats) {
|
|
64
|
+
const previous = state.files[path];
|
|
65
|
+
if (!previous)
|
|
66
|
+
added += 1;
|
|
67
|
+
else if (previous.size !== stat.size || previous.mtimeMs !== stat.mtimeMs)
|
|
68
|
+
changed += 1;
|
|
69
|
+
}
|
|
70
|
+
for (const path of Object.keys(state.files))
|
|
71
|
+
if (!stats.has(path))
|
|
72
|
+
removed += 1;
|
|
73
|
+
return { added, changed, removed };
|
|
74
|
+
}
|
|
75
|
+
function buildMetadataCache(state, stats) {
|
|
76
|
+
const cache = new Map();
|
|
77
|
+
for (const [path, stat] of stats) {
|
|
78
|
+
const previous = state.files[path];
|
|
79
|
+
if (!previous || previous.size !== stat.size || previous.mtimeMs !== stat.mtimeMs)
|
|
80
|
+
continue;
|
|
81
|
+
cache.set(path, deserializeMetadata(previous.metadata));
|
|
82
|
+
}
|
|
83
|
+
return cache;
|
|
84
|
+
}
|
|
85
|
+
function updateWatchStateFromScan(input) {
|
|
86
|
+
const next = {};
|
|
87
|
+
for (const [path, stat] of input.stats) {
|
|
88
|
+
const metadata = input.metadataByPath.get(path) ?? input.cache.get(path);
|
|
89
|
+
if (!metadata)
|
|
90
|
+
continue;
|
|
91
|
+
const previous = input.state.files[path];
|
|
92
|
+
const unchanged = previous && previous.size === stat.size && previous.mtimeMs === stat.mtimeMs;
|
|
93
|
+
next[path] = {
|
|
94
|
+
size: stat.size,
|
|
95
|
+
mtimeMs: stat.mtimeMs,
|
|
96
|
+
sessionId: `${input.format.source}:${metadata.externalSessionId}`,
|
|
97
|
+
metadata: serializeMetadata(metadata),
|
|
98
|
+
status: unchanged ? previous.status : "active",
|
|
99
|
+
...(unchanged
|
|
100
|
+
? {
|
|
101
|
+
...(previous.contentHash ? { contentHash: previous.contentHash } : {}),
|
|
102
|
+
...(previous.receiptKey ? { receiptKey: previous.receiptKey } : {}),
|
|
103
|
+
...(previous.importedAt ? { importedAt: previous.importedAt } : {}),
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
input.state.files = next;
|
|
109
|
+
}
|
|
110
|
+
function markImported(state, prepared, receipts) {
|
|
111
|
+
const file = state.files[prepared.candidate.path];
|
|
112
|
+
if (!file || !prepared.receiptKey)
|
|
113
|
+
return;
|
|
114
|
+
file.status = "imported";
|
|
115
|
+
file.contentHash = prepared.sourceContentHash;
|
|
116
|
+
file.receiptKey = prepared.receiptKey;
|
|
117
|
+
file.importedAt = receipts.receipts[prepared.receiptKey]?.importedAt ?? new Date().toISOString();
|
|
118
|
+
}
|
|
119
|
+
function markSessionImported(state, pending, receipts) {
|
|
120
|
+
const session = state.sessions[pending.sessionKey];
|
|
121
|
+
if (!session || !pending.prepared.receiptKey)
|
|
122
|
+
return;
|
|
123
|
+
session.status = "imported";
|
|
124
|
+
session.contentHash = pending.prepared.sourceContentHash;
|
|
125
|
+
session.receiptKey = pending.prepared.receiptKey;
|
|
126
|
+
session.importedAt =
|
|
127
|
+
receipts.receipts[pending.prepared.receiptKey]?.importedAt ?? new Date().toISOString();
|
|
128
|
+
}
|
|
129
|
+
export async function runWatchOnce(args, context, dependencies = {}) {
|
|
130
|
+
const agent = args.agent;
|
|
131
|
+
if (!agent)
|
|
132
|
+
throw new Error("Choose an agent with --agent. Watch is non-interactive.");
|
|
133
|
+
const format = sessionImportFormat(agent);
|
|
134
|
+
if (format.discovery !== "files")
|
|
135
|
+
throw new Error("watch --once currently supports file-backed sessions (Codex and Claude Code).");
|
|
136
|
+
const home = resolve(context.env[format.homeEnvironmentVariable] ||
|
|
137
|
+
resolve(context.homeDir, format.defaultHomeDirectory));
|
|
138
|
+
const repository = args.allRepositories ? undefined : repositoryIdentity(context.repoDir);
|
|
139
|
+
const apiKey = args.apiKey ?? context.env.GROUNDTRACK_IMPORT_KEY ?? context.env.GROUNDTRACK_API_KEY ?? null;
|
|
140
|
+
if (args.yes && !args.dryRun && !apiKey)
|
|
141
|
+
throw new Error("Set GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY before uploading.");
|
|
142
|
+
const providedReceiptScope = args.receiptScope ?? context.env.GROUNDTRACK_IMPORT_RECEIPT_SCOPE;
|
|
143
|
+
if (providedReceiptScope && !/^[a-f0-9]{64}$/.test(providedReceiptScope))
|
|
144
|
+
throw new Error("The import receipt scope must be a 64-character lowercase hex value.");
|
|
145
|
+
const stableReceiptScope = apiKey?.startsWith("gti_") ? providedReceiptScope : undefined;
|
|
146
|
+
const receipts = apiKey ? readReceipts(context) : { version: 1, receipts: {} };
|
|
147
|
+
const state = readWatchState(context, format.source);
|
|
148
|
+
const stats = statSessionFiles(format, home);
|
|
149
|
+
const diff = diffWatchFiles(state, stats);
|
|
150
|
+
const cache = buildMetadataCache(state, stats);
|
|
151
|
+
const nowMs = dependencies.now?.() ?? Date.now();
|
|
152
|
+
const discovery = await discoverSessions({
|
|
153
|
+
home,
|
|
154
|
+
format,
|
|
155
|
+
repository,
|
|
156
|
+
allRepositories: args.allRepositories,
|
|
157
|
+
includeUnscoped: args.includeUnscoped,
|
|
158
|
+
includeSubagents: args.includeSubagents,
|
|
159
|
+
since: args.since,
|
|
160
|
+
nowMs,
|
|
161
|
+
metadataCache: cache,
|
|
162
|
+
userHome: context.homeDir,
|
|
163
|
+
env: context.env,
|
|
164
|
+
platform: context.platform,
|
|
165
|
+
workingDirectory: context.repoDir,
|
|
166
|
+
});
|
|
167
|
+
const liveSessionIds = new Set();
|
|
168
|
+
if (format.agent === "codex") {
|
|
169
|
+
for (const item of discovery.skipped) {
|
|
170
|
+
if (item.reason !== "live")
|
|
171
|
+
continue;
|
|
172
|
+
const metadata = discovery.metadataByPath.get(item.path);
|
|
173
|
+
if (metadata && !metadata.isSubagent)
|
|
174
|
+
liveSessionIds.add(metadata.externalSessionId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const grouped = format.agent === "codex"
|
|
178
|
+
? await groupCodexPages({
|
|
179
|
+
candidates: discovery.included.filter((candidate) => !liveSessionIds.has(candidate.metadata.externalSessionId)),
|
|
180
|
+
metadataByPath: discovery.metadataByPath,
|
|
181
|
+
})
|
|
182
|
+
: {
|
|
183
|
+
candidates: discovery.included.map((candidate) => ({
|
|
184
|
+
candidate,
|
|
185
|
+
pages: [candidate.path],
|
|
186
|
+
merged: false,
|
|
187
|
+
})),
|
|
188
|
+
failed: [],
|
|
189
|
+
cleanup: () => { },
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const useSessionState = format.agent === "codex";
|
|
193
|
+
const failures = [
|
|
194
|
+
...discovery.failed,
|
|
195
|
+
...grouped.failed,
|
|
196
|
+
];
|
|
197
|
+
const prepared = [];
|
|
198
|
+
const alreadyPrepared = [];
|
|
199
|
+
const skipped = skippedCounts(discovery.skipped);
|
|
200
|
+
let alreadyImported = 0;
|
|
201
|
+
for (const group of grouped.candidates) {
|
|
202
|
+
const candidate = group.candidate;
|
|
203
|
+
const sessionKey = `${format.source}:${candidate.metadata.externalSessionId}`;
|
|
204
|
+
const fingerprint = fingerprintPages(group.pages, stats);
|
|
205
|
+
if (useSessionState) {
|
|
206
|
+
const session = state.sessions[sessionKey];
|
|
207
|
+
if (session?.fingerprint === fingerprint &&
|
|
208
|
+
session.receiptKey &&
|
|
209
|
+
receipts.receipts[session.receiptKey]) {
|
|
210
|
+
session.status = "imported";
|
|
211
|
+
session.importedAt = receipts.receipts[session.receiptKey].importedAt;
|
|
212
|
+
alreadyImported += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const previous = state.files[candidate.path];
|
|
218
|
+
const current = stats.get(candidate.path);
|
|
219
|
+
const unchanged = Boolean(previous &&
|
|
220
|
+
current &&
|
|
221
|
+
previous.size === current.size &&
|
|
222
|
+
previous.mtimeMs === current.mtimeMs);
|
|
223
|
+
if (unchanged && previous.receiptKey && receipts.receipts[previous.receiptKey]) {
|
|
224
|
+
previous.status = "imported";
|
|
225
|
+
previous.importedAt = receipts.receipts[previous.receiptKey].importedAt;
|
|
226
|
+
alreadyImported += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const item = await prepareSession({
|
|
232
|
+
candidate,
|
|
233
|
+
format,
|
|
234
|
+
apiBaseUrl: args.apiBaseUrl,
|
|
235
|
+
apiKey,
|
|
236
|
+
receiptScope: stableReceiptScope,
|
|
237
|
+
visibility: args.visibility,
|
|
238
|
+
receipts,
|
|
239
|
+
});
|
|
240
|
+
const pending = {
|
|
241
|
+
prepared: item,
|
|
242
|
+
sessionKey,
|
|
243
|
+
fingerprint,
|
|
244
|
+
pages: group.pages,
|
|
245
|
+
};
|
|
246
|
+
if (item.alreadyImported) {
|
|
247
|
+
alreadyImported += 1;
|
|
248
|
+
alreadyPrepared.push(pending);
|
|
249
|
+
}
|
|
250
|
+
else
|
|
251
|
+
prepared.push(pending);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
failures.push({ path: candidate.path, error: errorMessage(error) });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
updateWatchStateFromScan({
|
|
258
|
+
state,
|
|
259
|
+
format,
|
|
260
|
+
stats,
|
|
261
|
+
metadataByPath: discovery.metadataByPath,
|
|
262
|
+
cache,
|
|
263
|
+
});
|
|
264
|
+
if (useSessionState) {
|
|
265
|
+
for (const group of grouped.candidates) {
|
|
266
|
+
const sessionKey = `${format.source}:${group.candidate.metadata.externalSessionId}`;
|
|
267
|
+
const fingerprint = fingerprintPages(group.pages, stats);
|
|
268
|
+
const previous = state.sessions[sessionKey];
|
|
269
|
+
const unchanged = previous?.fingerprint === fingerprint;
|
|
270
|
+
state.sessions[sessionKey] = {
|
|
271
|
+
pages: [...group.pages],
|
|
272
|
+
fingerprint,
|
|
273
|
+
status: unchanged ? previous.status : "active",
|
|
274
|
+
...(unchanged
|
|
275
|
+
? {
|
|
276
|
+
...(previous.contentHash ? { contentHash: previous.contentHash } : {}),
|
|
277
|
+
...(previous.receiptKey ? { receiptKey: previous.receiptKey } : {}),
|
|
278
|
+
...(previous.importedAt ? { importedAt: previous.importedAt } : {}),
|
|
279
|
+
}
|
|
280
|
+
: {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const pending of alreadyPrepared) {
|
|
285
|
+
if (useSessionState)
|
|
286
|
+
markSessionImported(state, pending, receipts);
|
|
287
|
+
else
|
|
288
|
+
markImported(state, pending.prepared, receipts);
|
|
289
|
+
}
|
|
290
|
+
const scope = args.allRepositories ? "all repositories" : repository.root;
|
|
291
|
+
const result = (uploaded, duplicate) => ({
|
|
292
|
+
dryRun: args.dryRun,
|
|
293
|
+
preview: !args.yes,
|
|
294
|
+
agent,
|
|
295
|
+
scope,
|
|
296
|
+
scanned: stats.size,
|
|
297
|
+
added: diff.added,
|
|
298
|
+
changed: diff.changed,
|
|
299
|
+
removed: diff.removed,
|
|
300
|
+
cacheHits: discovery.cacheHits,
|
|
301
|
+
cacheMisses: discovery.cacheMisses,
|
|
302
|
+
active: discovery.skipped.filter((item) => item.reason === "live").length,
|
|
303
|
+
complete: prepared.length,
|
|
304
|
+
alreadyImported,
|
|
305
|
+
uploaded,
|
|
306
|
+
duplicate,
|
|
307
|
+
failed: failures,
|
|
308
|
+
skipped,
|
|
309
|
+
cursorPath: watchStatePath(context, format.source),
|
|
310
|
+
sessionsUrl: args.apiBaseUrl.replace(/\/api$/, "/sessions"),
|
|
311
|
+
});
|
|
312
|
+
if (args.dryRun || !args.yes)
|
|
313
|
+
return result(0, 0);
|
|
314
|
+
if (!apiKey)
|
|
315
|
+
throw new Error("Set GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY before uploading.");
|
|
316
|
+
const uploadKey = apiKey;
|
|
317
|
+
let uploaded = 0;
|
|
318
|
+
let duplicate = 0;
|
|
319
|
+
for (let offset = 0; offset < prepared.length; offset += 2) {
|
|
320
|
+
await Promise.all(prepared.slice(offset, offset + 2).map(async (pending) => {
|
|
321
|
+
try {
|
|
322
|
+
const upload = await uploadPreparedSession({
|
|
323
|
+
prepared: pending.prepared,
|
|
324
|
+
format,
|
|
325
|
+
apiBaseUrl: args.apiBaseUrl,
|
|
326
|
+
apiKey: uploadKey,
|
|
327
|
+
visibility: args.visibility,
|
|
328
|
+
context,
|
|
329
|
+
receipts,
|
|
330
|
+
fetch: dependencies.fetch,
|
|
331
|
+
});
|
|
332
|
+
if (upload.duplicate)
|
|
333
|
+
duplicate += 1;
|
|
334
|
+
else
|
|
335
|
+
uploaded += 1;
|
|
336
|
+
if (useSessionState)
|
|
337
|
+
markSessionImported(state, pending, receipts);
|
|
338
|
+
else
|
|
339
|
+
markImported(state, pending.prepared, receipts);
|
|
340
|
+
writeWatchState(context, format.source, state);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
failures.push({
|
|
344
|
+
path: pending.prepared.candidate.path,
|
|
345
|
+
error: errorMessage(error),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
writeWatchState(context, format.source, state);
|
|
351
|
+
return result(uploaded, duplicate);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
grouped.cleanup();
|
|
355
|
+
discovery.cleanup?.();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function printWatchResult(result, input) {
|
|
359
|
+
const skipped = Object.entries(result.skipped)
|
|
360
|
+
.map(([reason, count]) => `${reason} ${count}`)
|
|
361
|
+
.join(" · ");
|
|
362
|
+
const lines = [
|
|
363
|
+
`\nGroundtrack · watch ${result.agent} (once)`,
|
|
364
|
+
`Scope: ${result.scope}`,
|
|
365
|
+
`Scanned: ${result.scanned} files (${result.added} new, ${result.changed} changed, ${result.removed} removed)`,
|
|
366
|
+
`Active: ${result.active} · complete: ${result.complete} · already imported: ${result.alreadyImported}`,
|
|
367
|
+
`Uploaded: ${result.uploaded} (duplicates ${result.duplicate}) · failed: ${result.failed.length}`,
|
|
368
|
+
...(skipped ? [`Skipped: ${skipped}`] : []),
|
|
369
|
+
`Cursor: ${result.cursorPath}`,
|
|
370
|
+
...result.failed.slice(0, 5).map((item) => ` ! ${item.path}: ${item.error}`),
|
|
371
|
+
];
|
|
372
|
+
if (result.dryRun)
|
|
373
|
+
lines.push("Dry run: no uploads, no cursor writes.");
|
|
374
|
+
else if (!input.yes)
|
|
375
|
+
lines.push("Preview only. Run with --yes to upload.");
|
|
376
|
+
lines.push("");
|
|
377
|
+
process.stdout.write(lines.join("\n"));
|
|
378
|
+
}
|
|
379
|
+
export async function runWatchCli(argv) {
|
|
380
|
+
const args = parseWatchArguments(argv);
|
|
381
|
+
if (args.help) {
|
|
382
|
+
process.stdout.write(WATCH_USAGE);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!args.agent)
|
|
386
|
+
throw new Error("Choose an agent with --agent. Watch is non-interactive.");
|
|
387
|
+
const context = environment({ repoDir: args.repoDir, homeDir: args.homeDir });
|
|
388
|
+
const format = sessionImportFormat(args.agent);
|
|
389
|
+
const lock = args.yes && !args.dryRun ? acquireWatchLock(context, format.source) : null;
|
|
390
|
+
try {
|
|
391
|
+
const result = await runWatchOnce(args, context);
|
|
392
|
+
if (args.json)
|
|
393
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
394
|
+
else
|
|
395
|
+
printWatchResult(result, { yes: args.yes });
|
|
396
|
+
if (result.failed.length)
|
|
397
|
+
process.exitCode = 1;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
lock?.release();
|
|
401
|
+
}
|
|
402
|
+
}
|
package/dist/setup/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ Usage:
|
|
|
18
18
|
groundtrack status Check selected agents
|
|
19
19
|
groundtrack doctor [--fix] Check or repair using the same flow
|
|
20
20
|
groundtrack import [options] Import local coding-agent sessions
|
|
21
|
+
groundtrack watch --once [options] Sync completed sessions once
|
|
21
22
|
groundtrack version
|
|
22
23
|
|
|
23
24
|
Options:
|